Compare commits
12 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a2952c6f3 | ||
|
|
03b63dd5d0 | ||
|
|
7717dffc56 | ||
|
|
3a2cab7ca7 | ||
|
|
e0b5411eb1 | ||
|
|
f85adfa007 | ||
|
|
88c3cd57c1 | ||
|
|
d67215a6eb | ||
|
|
5cc2fdd8b4 | ||
|
|
38bce3fecf | ||
|
|
f1c75c468a | ||
|
|
bfb152f0d8 |
@@ -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 () {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod use_random;
|
pub mod use_random;
|
||||||
pub mod use_theme_mode;
|
pub mod use_theme_mode;
|
||||||
|
pub mod use_can_scroll_vertical;
|
||||||
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
/// Hook to determine if an element can scroll vertically.
|
||||||
|
///
|
||||||
|
/// Returns (on_scroll_callback, can_scroll_up_signal, can_scroll_down_signal)
|
||||||
|
pub fn use_can_scroll_vertical() -> (Callback<web_sys::Event>, ReadSignal<bool>, ReadSignal<bool>) {
|
||||||
|
let can_scroll_up = RwSignal::new(false);
|
||||||
|
let can_scroll_down = RwSignal::new(false);
|
||||||
|
|
||||||
|
let on_scroll = Callback::new(move |ev: web_sys::Event| {
|
||||||
|
if let Some(target) = ev.target() {
|
||||||
|
if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let scroll_top = el.scroll_top();
|
||||||
|
let scroll_height = el.scroll_height();
|
||||||
|
let client_height = el.client_height();
|
||||||
|
|
||||||
|
can_scroll_up.set(scroll_top > 0);
|
||||||
|
can_scroll_down.set(scroll_top + client_height < scroll_height - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(on_scroll, can_scroll_up.read_only(), can_scroll_down.read_only())
|
||||||
|
}
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||||
|
use crate::components::ui::button::{Button};
|
||||||
|
|
||||||
#[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 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 + Add Torrent
|
||||||
@@ -27,33 +19,20 @@ pub fn Toolbar() -> impl IntoView {
|
|||||||
<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>
|
<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>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
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]"
|
|
||||||
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şaltıldı (arama kutusu kaldırıldı)
|
||||||
<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()>
|
||||||
@@ -61,4 +40,4 @@ pub fn Toolbar() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis};
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||||
use crate::components::ui::data_table::*;
|
use crate::components::ui::data_table::*;
|
||||||
|
use crate::components::ui::checkbox::Checkbox;
|
||||||
|
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||||
|
use crate::components::ui::empty::*;
|
||||||
|
use crate::components::ui::input::Input;
|
||||||
|
use crate::components::ui::multi_select::*;
|
||||||
|
use crate::components::ui::dropdown_menu::*;
|
||||||
|
use crate::components::ui::alert_dialog::*;
|
||||||
|
|
||||||
|
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||||
|
("Name", "Name"),
|
||||||
|
("Size", "Size"),
|
||||||
|
("Progress", "Progress"),
|
||||||
|
("Status", "Status"),
|
||||||
|
("DownSpeed", "DL Speed"),
|
||||||
|
("UpSpeed", "UP Speed"),
|
||||||
|
("ETA", "ETA"),
|
||||||
|
("AddedDate", "Date"),
|
||||||
|
];
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -50,8 +70,16 @@ pub fn TorrentTable() -> 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 sort_col = signal(SortColumn::AddedDate);
|
let sort_col = signal(SortColumn::AddedDate);
|
||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
|
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
||||||
|
|
||||||
|
let visible_columns = RwSignal::new(HashSet::from([
|
||||||
|
"Name".to_string(), "Size".to_string(), "Progress".to_string(),
|
||||||
|
"Status".to_string(), "DownSpeed".to_string(), "UpSpeed".to_string(),
|
||||||
|
"ETA".to_string(), "AddedDate".to_string()
|
||||||
|
]));
|
||||||
|
|
||||||
let filtered_hashes = Memo::new(move |_| {
|
let sorted_hashes_data = Memo::new(move |_| {
|
||||||
let torrents_map = store.torrents.get();
|
let torrents_map = store.torrents.get();
|
||||||
let filter = store.filter.get();
|
let filter = store.filter.get();
|
||||||
let search = store.search_query.get();
|
let search = store.search_query.get();
|
||||||
@@ -90,7 +118,30 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||||
});
|
});
|
||||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
torrents
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered_hashes = Memo::new(move |_| {
|
||||||
|
sorted_hashes_data.get().into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let selected_count = Signal::derive(move || {
|
||||||
|
let current_hashes: HashSet<String> = filtered_hashes.get().into_iter().collect();
|
||||||
|
selected_hashes.with(|selected| {
|
||||||
|
selected.iter().filter(|h| current_hashes.contains(*h)).count()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_selection = Signal::derive(move || selected_count.get() > 0);
|
||||||
|
|
||||||
|
let handle_select_all = Callback::new(move |checked: bool| {
|
||||||
|
selected_hashes.update(|selected| {
|
||||||
|
let hashes = filtered_hashes.get_untracked();
|
||||||
|
for h in hashes {
|
||||||
|
if checked { selected.insert(h); }
|
||||||
|
else { selected.remove(&h); }
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let handle_sort = move |col: SortColumn| {
|
let handle_sort = move |col: SortColumn| {
|
||||||
@@ -104,13 +155,35 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sort_arrow = move |col: SortColumn| {
|
let sort_icon = move |col: SortColumn| {
|
||||||
if sort_col.0.get() == col {
|
let is_active = sort_col.0.get() == col;
|
||||||
match sort_dir.0.get() {
|
let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" };
|
||||||
SortDirection::Ascending => view! { <span class="ml-1 text-[10px]">"▲"</span> }.into_any(),
|
view! { <ArrowUpDown class=class.to_string() /> }.into_any()
|
||||||
SortDirection::Descending => view! { <span class="ml-1 text-[10px]">"▼"</span> }.into_any(),
|
};
|
||||||
|
|
||||||
|
let bulk_action = move |action: &'static str| {
|
||||||
|
let hashes: Vec<String> = selected_hashes.get().into_iter().collect();
|
||||||
|
if hashes.is_empty() { return; }
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let mut success = true;
|
||||||
|
for hash in hashes {
|
||||||
|
let res = match action {
|
||||||
|
"start" => api::torrent::start(&hash).await,
|
||||||
|
"stop" => api::torrent::stop(&hash).await,
|
||||||
|
"delete" => api::torrent::delete(&hash).await,
|
||||||
|
"delete_with_data" => api::torrent::delete_with_data(&hash).await,
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if res.is_err() { success = false; }
|
||||||
}
|
}
|
||||||
} else { view! { <span class="ml-1 text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">"▲"</span> }.into_any() }
|
if success {
|
||||||
|
show_toast(NotificationLevel::Success, format!("Toplu işlem başarıyla tamamlandı: {}", action));
|
||||||
|
selected_hashes.update(|s| s.clear());
|
||||||
|
} else {
|
||||||
|
show_toast(NotificationLevel::Error, "Bazı işlemler başarısız oldu.");
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
@@ -133,69 +206,217 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
|
||||||
// --- DESKTOP VIEW ---
|
// --- TOPBAR ---
|
||||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<DataTableWrapper class="flex-1 min-h-0 bg-card/50">
|
<div class="flex items-center gap-2 flex-1 max-w-md">
|
||||||
|
<Input
|
||||||
|
class="h-9"
|
||||||
|
placeholder="Torrent ara..."
|
||||||
|
bind_value=store.search_query
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Show when=move || has_selection.get()>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger class="gap-2 bg-secondary text-secondary-foreground border-none hover:bg-secondary/80">
|
||||||
|
<Ellipsis class="size-4" />
|
||||||
|
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="w-48">
|
||||||
|
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||||
|
<DropdownMenuGroup class="mt-2">
|
||||||
|
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||||
|
<Play class="mr-2 size-4" /> "Başlat"
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||||
|
<Square class="mr-2 size-4" /> "Durdur"
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<div class="my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger class="w-full text-left">
|
||||||
|
<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="size-4" /> "Toplu Sil"
|
||||||
|
</div>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogClose>"İptal"</AlertDialogClose>
|
||||||
|
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")>
|
||||||
|
"Sil"
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<MultiSelect values=visible_columns>
|
||||||
|
<MultiSelectTrigger class="w-[140px] h-9">
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<Settings2 class="size-4" />
|
||||||
|
"Sütunlar"
|
||||||
|
</div>
|
||||||
|
</MultiSelectTrigger>
|
||||||
|
<MultiSelectContent>
|
||||||
|
<MultiSelectGroup>
|
||||||
|
{ALL_COLUMNS.into_iter().map(|(id, label)| {
|
||||||
|
let id_val = id.to_string();
|
||||||
|
view! {
|
||||||
|
<MultiSelectItem>
|
||||||
|
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
|
||||||
|
{label}
|
||||||
|
</MultiSelectOption>
|
||||||
|
</MultiSelectItem>
|
||||||
|
}.into_any()
|
||||||
|
}).collect_view()}
|
||||||
|
</MultiSelectGroup>
|
||||||
|
</MultiSelectContent>
|
||||||
|
</MultiSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// --- MAIN TABLE ---
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<DataTableWrapper class="h-full bg-card/50">
|
||||||
<div class="h-full overflow-auto">
|
<div class="h-full overflow-auto">
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
<DataTableRow class="hover:bg-transparent">
|
<DataTableRow class="hover:bg-transparent">
|
||||||
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
<DataTableHead class="w-12 px-4">
|
||||||
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
|
<Checkbox
|
||||||
</DataTableHead>
|
checked=Signal::derive(move || {
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
let hashes = filtered_hashes.get();
|
||||||
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
|
!hashes.is_empty() && selected_count.get() == hashes.len()
|
||||||
</DataTableHead>
|
})
|
||||||
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
on_checked_change=handle_select_all
|
||||||
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
|
/>
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
|
||||||
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
|
||||||
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
|
||||||
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
|
|
||||||
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-32 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
|
||||||
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
|
|
||||||
</DataTableHead>
|
</DataTableHead>
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Name").then(|| view! {
|
||||||
|
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
|
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Size").then(|| view! {
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||||
|
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Progress").then(|| view! {
|
||||||
|
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
|
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Status").then(|| view! {
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
|
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("DownSpeed").then(|| view! {
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
|
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("UpSpeed").then(|| view! {
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
|
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("ETA").then(|| view! {
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
|
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("AddedDate").then(|| view! {
|
||||||
|
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
|
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody>
|
<DataTableBody>
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
<Show
|
||||||
let on_action = on_action.clone();
|
when=move || !filtered_hashes.get().is_empty()
|
||||||
move |hash| {
|
fallback=move || view! {
|
||||||
view! {
|
<DataTableRow class="hover:bg-transparent">
|
||||||
<TorrentRow hash=hash.clone() on_action=on_action.clone() />
|
<DataTableCell attr:colspan="10" class="h-[400px]">
|
||||||
|
<Empty class="h-full">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
||||||
|
<Inbox class="size-10 text-muted-foreground" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
{move || {
|
||||||
|
let query = store.search_query.get();
|
||||||
|
if query.is_empty() { "Henüz torrent bulunmuyor.".to_string() }
|
||||||
|
else { "Arama kriterlerinize uygun sonuç bulunamadı.".to_string() }
|
||||||
|
}}
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
}.into_any()
|
||||||
|
>
|
||||||
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
|
let on_action = on_action.clone();
|
||||||
|
move |hash| {
|
||||||
|
let h = hash.clone();
|
||||||
|
let is_selected = Signal::derive(move || {
|
||||||
|
selected_hashes.with(|selected| selected.contains(&h))
|
||||||
|
});
|
||||||
|
let h_for_change = hash.clone();
|
||||||
|
view! {
|
||||||
|
<TorrentRow
|
||||||
|
hash=hash.clone()
|
||||||
|
on_action=on_action.clone()
|
||||||
|
is_selected=is_selected
|
||||||
|
visible_columns=visible_columns
|
||||||
|
on_select=Callback::new(move |checked| {
|
||||||
|
selected_hashes.update(|selected| {
|
||||||
|
if checked { selected.insert(h_for_change.clone()); }
|
||||||
|
else { selected.remove(&h_for_change); }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} />
|
||||||
} />
|
</Show>
|
||||||
</DataTableBody>
|
</DataTableBody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</DataTableWrapper>
|
</DataTableWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- MOBILE VIEW ---
|
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
|
||||||
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
<div class="flex gap-4">
|
||||||
<div class="flex-1 overflow-y-auto p-3 min-h-0">
|
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
<Show when=move || has_selection.get()>
|
||||||
let on_action = on_action.clone();
|
<span class="text-primary font-medium">{move || format!("{} torrent seçili", selected_count.get())}</span>
|
||||||
move |hash| {
|
</Show>
|
||||||
view! {
|
|
||||||
<div class="pb-3">
|
|
||||||
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>"VibeTorrent v3"</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
@@ -205,6 +426,9 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
fn TorrentRow(
|
fn TorrentRow(
|
||||||
hash: String,
|
hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
|
is_selected: Signal<bool>,
|
||||||
|
visible_columns: RwSignal<HashSet<String>>,
|
||||||
|
on_select: Callback<bool>,
|
||||||
) -> impl IntoView {
|
) -> 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 h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -221,51 +445,95 @@ fn TorrentRow(
|
|||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
||||||
|
|
||||||
let is_selected = Memo::new(move |_| {
|
let is_active_selection = Memo::new(move |_| {
|
||||||
let selected = store.selected_torrent.get();
|
let selected = store.selected_torrent.get();
|
||||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||||
});
|
});
|
||||||
|
|
||||||
let t_name_for_title = t_name.clone();
|
let t_name_stored = StoredValue::new(t_name.clone());
|
||||||
let t_name_for_content = t_name.clone();
|
|
||||||
let h_for_menu = stored_hash.get_value();
|
let h_for_menu = stored_hash.get_value();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||||
<DataTableRow
|
<DataTableRow
|
||||||
class="cursor-pointer group"
|
class="cursor-pointer group h-10"
|
||||||
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
|
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
|
<DataTableCell class="w-12 px-4">
|
||||||
{t_name_for_content}
|
<Checkbox
|
||||||
</DataTableCell>
|
checked=is_selected
|
||||||
<DataTableCell class="font-mono text-xs text-muted-foreground">
|
on_checked_change=on_select
|
||||||
{format_bytes(t.size)}
|
/>
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
|
||||||
</div>
|
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
|
|
||||||
{format!("{:?}", t.status)}
|
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
|
|
||||||
{format_speed(t.down_rate)}
|
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
|
|
||||||
{format_speed(t.up_rate)}
|
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{format_duration(t.eta)}
|
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{format_date(t.added_date)}
|
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Name").then({
|
||||||
|
move || view! {
|
||||||
|
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_stored.get_value()>
|
||||||
|
{t_name_stored.get_value()}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Size").then({
|
||||||
|
let size_bytes = t.size;
|
||||||
|
move || {
|
||||||
|
let size_str = format_bytes(size_bytes);
|
||||||
|
view! { <DataTableCell class="font-mono text-xs text-muted-foreground whitespace-nowrap">{size_str}</DataTableCell> }
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Progress").then({
|
||||||
|
let percent = t.percent_complete;
|
||||||
|
move || view! {
|
||||||
|
<DataTableCell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
|
||||||
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", percent)></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", percent)}</span>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Status").then({
|
||||||
|
let status_text = format!("{:?}", t.status);
|
||||||
|
let color = status_color;
|
||||||
|
move || view! { <DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", color)}>{status_text.clone()}</DataTableCell> }
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("DownSpeed").then({
|
||||||
|
let rate = t.down_rate;
|
||||||
|
move || {
|
||||||
|
let speed_str = format_speed(rate);
|
||||||
|
view! { <DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("UpSpeed").then({
|
||||||
|
let rate = t.up_rate;
|
||||||
|
move || {
|
||||||
|
let speed_str = format_speed(rate);
|
||||||
|
view! { <DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("ETA").then({
|
||||||
|
let eta = t.eta;
|
||||||
|
move || {
|
||||||
|
let eta_str = format_duration(eta);
|
||||||
|
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{eta_str}</DataTableCell> }
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("AddedDate").then({
|
||||||
|
let date = t.added_date;
|
||||||
|
move || {
|
||||||
|
let date_str = format_date(date);
|
||||||
|
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{date_str}</DataTableCell> }
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
</TorrentContextMenu>
|
</TorrentContextMenu>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
|
|||||||
94
frontend/src/components/ui/alert_dialog.rs
Normal file
94
frontend/src/components/ui/alert_dialog.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::ui::button::{ButtonSize, ButtonVariant};
|
||||||
|
use crate::components::ui::dialog::{
|
||||||
|
Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! { <Dialog class=class>{children()}</Dialog> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||||
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogTrigger class=class variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</DialogTrigger>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
|
||||||
|
{children()}
|
||||||
|
</DialogContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogBody class=class attr:data-name="AlertDialogBody">
|
||||||
|
{children()}
|
||||||
|
</DialogBody>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogHeader class=class attr:data-name="AlertDialogHeader">
|
||||||
|
{children()}
|
||||||
|
</DialogHeader>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogTitle class=class attr:data-name="AlertDialogTitle">
|
||||||
|
{children()}
|
||||||
|
</DialogTitle>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogDescription class=class attr:data-name="AlertDialogDescription">
|
||||||
|
{children()}
|
||||||
|
</DialogDescription>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogFooter class=class attr:data-name="AlertDialogFooter">
|
||||||
|
{children()}
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogClose(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||||
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogClose class=class variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</DialogClose>
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/components/ui/checkbox.rs
Normal file
43
frontend/src/components/ui/checkbox.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use icons::Check;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Checkbox(
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(into, optional)] checked: Signal<bool>,
|
||||||
|
#[prop(into, optional)] disabled: Signal<bool>,
|
||||||
|
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
||||||
|
#[prop(into, optional, default = "Checkbox".to_string())] aria_label: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let checked_state = move || if checked.get() { "checked" } else { "unchecked" };
|
||||||
|
|
||||||
|
let checkbox_class = tw_merge!(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
data-name="Checkbox"
|
||||||
|
class=checkbox_class
|
||||||
|
data-state=checked_state
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked=move || checked.get().to_string()
|
||||||
|
aria-label=aria_label
|
||||||
|
disabled=move || disabled.get()
|
||||||
|
on:click=move |_| {
|
||||||
|
if !disabled.get() {
|
||||||
|
if let Some(callback) = on_checked_change {
|
||||||
|
callback.run(!checked.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span data-name="CheckboxIndicator" class="flex justify-center items-center text-current transition-none">
|
||||||
|
{move || { checked.get().then(|| view! { <Check class="size-3.5".to_string() /> }) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,7 +202,7 @@ pub fn ContextMenuTrigger(
|
|||||||
#[prop(optional)] on_open: Option<Callback<()>>,
|
#[prop(optional)] on_open: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<ContextMenuContext>();
|
let ctx = expect_context::<ContextMenuContext>();
|
||||||
let trigger_class = tw_merge!("block w-full h-full", class);
|
let trigger_class = tw_merge!("contents", class);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<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
|
||||||
|
|||||||
251
frontend/src/components/ui/dialog.rs
Normal file
251
frontend/src/components/ui/dialog.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
use icons::X;
|
||||||
|
use leptos::context::Provider;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::clx;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
use crate::components::ui::button::{Button, ButtonSize, ButtonVariant};
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
clx! {DialogBody, div, "flex flex-col gap-4"}
|
||||||
|
clx! {DialogHeader, div, "flex flex-col gap-2 text-center sm:text-left"}
|
||||||
|
clx! {DialogTitle, h3, "text-lg leading-none font-semibold"}
|
||||||
|
clx! {DialogDescription, p, "text-muted-foreground text-sm"}
|
||||||
|
clx! {DialogFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DialogContext {
|
||||||
|
target_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let dialog_target_id = use_random_id_for("dialog");
|
||||||
|
|
||||||
|
let ctx = DialogContext { target_id: dialog_target_id.clone() };
|
||||||
|
|
||||||
|
let merged_class = tw_merge!("w-fit", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<div class=merged_class data-name="__Dialog">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DialogTrigger(
|
||||||
|
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::<DialogContext>();
|
||||||
|
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:id=trigger_id
|
||||||
|
attr:tabindex="0"
|
||||||
|
attr:data-dialog-trigger=ctx.target_id
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DialogContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: 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
|
||||||
|
);
|
||||||
|
|
||||||
|
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
|
||||||
|
let content_data_name = format!("{}Content", data_name_prefix);
|
||||||
|
|
||||||
|
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" };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name=backdrop_data_name
|
||||||
|
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=content_data_name
|
||||||
|
class=merged_class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__dialog"
|
||||||
|
data-state="closed"
|
||||||
|
data-backdrop=backdrop_behavior
|
||||||
|
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-dialog-close=target_id_clone.clone()
|
||||||
|
aria-label="Close dialog"
|
||||||
|
>
|
||||||
|
<span class="hidden">"Close Dialog"</span>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupDialog = () => {{
|
||||||
|
const dialog = document.querySelector('#{}');
|
||||||
|
const backdrop = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!dialog || !backdrop || !trigger) {{
|
||||||
|
setTimeout(setupDialog, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (dialog.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
dialog.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
const openDialog = () => {{
|
||||||
|
// Lock scrolling
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
dialog.setAttribute('data-state', 'open');
|
||||||
|
backdrop.setAttribute('data-state', 'open');
|
||||||
|
dialog.style.pointerEvents = 'auto';
|
||||||
|
backdrop.style.pointerEvents = 'auto';
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeDialog = () => {{
|
||||||
|
dialog.setAttribute('data-state', 'closed');
|
||||||
|
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 => {{
|
||||||
|
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();
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupDialog);
|
||||||
|
}} else {{
|
||||||
|
setupDialog();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
backdrop_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DialogClose(
|
||||||
|
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::<DialogContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:data-dialog-close=ctx.target_id
|
||||||
|
attr:aria-label="Close dialog"
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DialogAction(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
||||||
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<DialogContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:data-dialog-close=ctx.target_id
|
||||||
|
attr:aria-label="Close dialog"
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
use icons::{Check, ChevronRight};
|
||||||
|
use leptos::context::Provider;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::clx;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||||
|
clx! {DropdownMenuGroup, ul, "group"}
|
||||||
|
clx! {DropdownMenuItem, 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! {DropdownMenuSubContent, ul, "dropdown__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! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* RADIO GROUP */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
||||||
|
value_signal: RwSignal<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of radio items where only one can be selected at a time.
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuRadioGroup<T>(
|
||||||
|
children: Children,
|
||||||
|
/// The signal holding the current selected value
|
||||||
|
value: RwSignal<T>,
|
||||||
|
) -> impl IntoView
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let ctx = DropdownMenuRadioContext { value_signal: value };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
||||||
|
{children()}
|
||||||
|
</ul>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A radio item that shows a checkmark when selected.
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuRadioItem<T>(
|
||||||
|
children: Children,
|
||||||
|
/// The value this item represents
|
||||||
|
value: T,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
) -> impl IntoView
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
||||||
|
|
||||||
|
let value_for_check = value.clone();
|
||||||
|
let value_for_click = value.clone();
|
||||||
|
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
||||||
|
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li
|
||||||
|
data-name="DropdownMenuRadioItem"
|
||||||
|
class=merged_class
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked=move || is_selected().to_string()
|
||||||
|
data-dropdown-close="true"
|
||||||
|
on:click=move |_| {
|
||||||
|
ctx.value_signal.set(value_for_click.clone());
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action item in a dropdown menu (no checkmark, just triggers an action).
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuAction(
|
||||||
|
children: Children,
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(href) = href {
|
||||||
|
// Render as <a> tag when href is provided
|
||||||
|
view! {
|
||||||
|
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{r#"
|
||||||
|
(function() {
|
||||||
|
const link = document.currentScript.previousElementSibling;
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
link.addEventListener('click', function() {
|
||||||
|
// Close dropdown on route change after navigation
|
||||||
|
let currentPath = window.location.pathname;
|
||||||
|
const checkRouteChange = () => {
|
||||||
|
if (window.location.pathname !== currentPath) {
|
||||||
|
currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
// Find and close the dropdown
|
||||||
|
const dropdown = link.closest('[data-target="target__dropdown"]');
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.setAttribute('data-state', 'closed');
|
||||||
|
dropdown.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Unlock scroll
|
||||||
|
if (window.ScrollLock) {
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(routeCheckInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeCheckInterval = setInterval(checkRouteChange, 50);
|
||||||
|
|
||||||
|
// Clear interval after 2 seconds to prevent memory leaks
|
||||||
|
setTimeout(() => clearInterval(routeCheckInterval), 2000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"#}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
// Render as <button> tag when no href
|
||||||
|
view! {
|
||||||
|
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum DropdownMenuAlign {
|
||||||
|
#[default]
|
||||||
|
Start,
|
||||||
|
StartOuter,
|
||||||
|
End,
|
||||||
|
EndOuter,
|
||||||
|
Center,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(), align };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<style>
|
||||||
|
"
|
||||||
|
/* Submenu Styles */
|
||||||
|
.dropdown__menu_sub_content {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: calc(100% + 8px);
|
||||||
|
inset-block-start: -4px;
|
||||||
|
z-index: 100;
|
||||||
|
min-inline-size: 160px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div data-name="DropdownMenu">{children()}</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<DropdownMenuContext>();
|
||||||
|
let button_class = tw_merge!(
|
||||||
|
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=button_class
|
||||||
|
data-name="DropdownMenuTrigger"
|
||||||
|
data-dropdown-trigger=ctx.target_id
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum DropdownMenuPosition {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[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 ctx.align {
|
||||||
|
DropdownMenuAlign::Center => "min-w-full",
|
||||||
|
_ => "w-[180px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
let class = tw_merge!(width_class, base_classes, class);
|
||||||
|
|
||||||
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
|
let align_for_script = match ctx.align {
|
||||||
|
DropdownMenuAlign::Start => "start",
|
||||||
|
DropdownMenuAlign::StartOuter => "start-outer",
|
||||||
|
DropdownMenuAlign::End => "end",
|
||||||
|
DropdownMenuAlign::EndOuter => "end-outer",
|
||||||
|
DropdownMenuAlign::Center => "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
let position_for_script = match position {
|
||||||
|
DropdownMenuPosition::Auto => "auto",
|
||||||
|
DropdownMenuPosition::Top => "top",
|
||||||
|
DropdownMenuPosition::Bottom => "bottom",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
data-name="DropdownMenuContent"
|
||||||
|
class=class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__dropdown"
|
||||||
|
data-state="closed"
|
||||||
|
data-align=align_for_script
|
||||||
|
data-position=position_for_script
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupDropdown = () => {{
|
||||||
|
const dropdown = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!dropdown || !trigger) {{
|
||||||
|
setTimeout(setupDropdown, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (dropdown.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
dropdown.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const updatePosition = () => {{
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const dropdownRect = dropdown.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||||
|
const spaceAbove = triggerRect.top;
|
||||||
|
|
||||||
|
const align = dropdown.getAttribute('data-align') || 'start';
|
||||||
|
const position = dropdown.getAttribute('data-position') || 'auto';
|
||||||
|
|
||||||
|
// Determine if we should position above
|
||||||
|
let shouldPositionAbove = false;
|
||||||
|
if (position === 'top') {{
|
||||||
|
shouldPositionAbove = true;
|
||||||
|
}} else if (position === 'bottom') {{
|
||||||
|
shouldPositionAbove = false;
|
||||||
|
}} else {{
|
||||||
|
// Auto: position above if there's space above AND not enough space below
|
||||||
|
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
|
||||||
|
}}
|
||||||
|
|
||||||
|
switch (align) {{
|
||||||
|
case 'start':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'end':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'start-outer':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'end-outer':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.right + 8}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'center':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'center bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'center top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||||
|
dropdown.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
break;
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
const openDropdown = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
|
||||||
|
// Set state to open first to remove scale transform for accurate measurements
|
||||||
|
dropdown.setAttribute('data-state', 'open');
|
||||||
|
|
||||||
|
// Make dropdown invisible but rendered to measure true height
|
||||||
|
dropdown.style.visibility = 'hidden';
|
||||||
|
dropdown.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Force reflow to ensure height is calculated
|
||||||
|
dropdown.offsetHeight;
|
||||||
|
|
||||||
|
// Calculate position with accurate height
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// Now make it visible
|
||||||
|
dropdown.style.visibility = 'visible';
|
||||||
|
|
||||||
|
// Lock all scrollable elements
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeDropdown = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
dropdown.setAttribute('data-state', 'closed');
|
||||||
|
dropdown.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
// Unlock scroll after animation (200ms delay)
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeDropdown();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Toggle dropdown when trigger is clicked
|
||||||
|
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();
|
||||||
|
}} else {{
|
||||||
|
openDropdown();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Close when action is clicked
|
||||||
|
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
|
||||||
|
actions.forEach(action => {{
|
||||||
|
action.addEventListener('click', () => {{
|
||||||
|
closeDropdown();
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupDropdown);
|
||||||
|
}} else {{
|
||||||
|
setupDropdown();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
|
||||||
|
// TODO. Find a better way for dropdown__menu_sub_trigger.
|
||||||
|
clx! {DropdownMenuSubRoot, li, "dropdown__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! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
||||||
|
<span class="flex gap-2 items-center">{children()}</span>
|
||||||
|
<ChevronRight class="opacity-70 size-4" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuSubItem(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! {
|
||||||
|
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/empty.rs
Normal file
35
frontend/src/components/ui/empty.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::{clx, variants};
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"}
|
||||||
|
clx! {EmptyHeader, div, "flex flex-col items-center gap-2"}
|
||||||
|
clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"}
|
||||||
|
clx! {EmptyDescription, p, "text-muted-foreground text-sm"}
|
||||||
|
clx! {EmptyContent, div, "flex items-center justify-center gap-2"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
variants! {
|
||||||
|
EmptyMedia {
|
||||||
|
base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
Default: "bg-transparent",
|
||||||
|
Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
Default: "",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: {
|
||||||
|
element: div
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
pub mod alert_dialog;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod input;
|
pub mod checkbox;
|
||||||
pub mod toast;
|
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod theme_toggle;
|
pub mod data_table;
|
||||||
|
pub mod dialog;
|
||||||
|
pub mod dropdown_menu;
|
||||||
|
pub mod empty;
|
||||||
|
pub mod input;
|
||||||
|
pub mod multi_select;
|
||||||
|
pub mod select;
|
||||||
|
pub mod separator;
|
||||||
pub mod svg_icon;
|
pub mod svg_icon;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod data_table;
|
pub mod theme_toggle;
|
||||||
|
pub mod toast;
|
||||||
294
frontend/src/components/ui/multi_select.rs
Normal file
294
frontend/src/components/ui/multi_select.rs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use icons::{Check, ChevronDown, ChevronUp};
|
||||||
|
use leptos::context::Provider;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
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, SelectLabel as MultiSelectLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum MultiSelectAlign {
|
||||||
|
Start,
|
||||||
|
#[default]
|
||||||
|
Center,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
||||||
|
{move || {
|
||||||
|
let values = multi_select_ctx.values_signal.get();
|
||||||
|
if values.is_empty() {
|
||||||
|
placeholder.clone()
|
||||||
|
} else {
|
||||||
|
let count = values.len();
|
||||||
|
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectOption(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] value: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
let value_clone = value.clone();
|
||||||
|
let is_selected = Signal::derive(move || {
|
||||||
|
if let Some(ref val) = value_clone {
|
||||||
|
multi_select_ctx.values_signal.with(|values| values.contains(val))
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"group 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 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="MultiSelectOption"
|
||||||
|
class=class
|
||||||
|
role="option"
|
||||||
|
aria-selected=move || is_selected.get().to_string()
|
||||||
|
on:click=move |ev: web_sys::MouseEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
ev.stop_propagation();
|
||||||
|
if let Some(val) = value.clone() {
|
||||||
|
multi_select_ctx
|
||||||
|
.values_signal
|
||||||
|
.update(|values| {
|
||||||
|
if values.contains(&val) {
|
||||||
|
values.remove(&val);
|
||||||
|
} else {
|
||||||
|
values.insert(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MultiSelectContext {
|
||||||
|
target_id: String,
|
||||||
|
values_signal: RwSignal<HashSet<String>>,
|
||||||
|
align: MultiSelectAlign,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelect(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
|
||||||
|
#[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let multi_select_target_id = use_random_id_for("multi_select");
|
||||||
|
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
|
||||||
|
|
||||||
|
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=multi_select_ctx>
|
||||||
|
<div data-name="MultiSelect" class="relative w-fit">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||||
|
|
||||||
|
let button_class = tw_merge!(
|
||||||
|
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||||
|
&peer_class,
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="MultiSelectTrigger"
|
||||||
|
class=button_class
|
||||||
|
id=button_id
|
||||||
|
tabindex="0"
|
||||||
|
data-multi-select-trigger=multi_select_ctx.target_id
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<ChevronDown class="text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
let align_str = match multi_select_ctx.align {
|
||||||
|
MultiSelectAlign::Start => "start",
|
||||||
|
MultiSelectAlign::Center => "center",
|
||||||
|
MultiSelectAlign::End => "end",
|
||||||
|
};
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_id_for_script = multi_select_ctx.target_id.clone();
|
||||||
|
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
|
||||||
|
|
||||||
|
// Scroll indicator signals
|
||||||
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
data-name="MultiSelectContent"
|
||||||
|
class=class
|
||||||
|
id=multi_select_ctx.target_id
|
||||||
|
data-target="target__multi_select"
|
||||||
|
data-state="closed"
|
||||||
|
data-align=align_str
|
||||||
|
style="pointer-events: none;"
|
||||||
|
on:scroll=move |ev| on_scroll.run(ev)
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-scroll-up="true"
|
||||||
|
class=move || {
|
||||||
|
let is_up: bool = can_scroll_up_signal.get();
|
||||||
|
if is_up {
|
||||||
|
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{children()}
|
||||||
|
<div
|
||||||
|
data-scroll-down="true"
|
||||||
|
class=move || {
|
||||||
|
let is_down: bool = can_scroll_down_signal.get();
|
||||||
|
if is_down {
|
||||||
|
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupMultiSelect = () => {{
|
||||||
|
const multiSelect = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!multiSelect || !trigger) {{
|
||||||
|
setTimeout(setupMultiSelect, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (multiSelect.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
multiSelect.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const openMultiSelect = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
|
multiSelect.setAttribute('data-state', 'open');
|
||||||
|
multiSelect.style.pointerEvents = 'auto';
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
multiSelect.dispatchEvent(new Event('scroll'));
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeMultiSelect = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
multiSelect.setAttribute('data-state', 'closed');
|
||||||
|
multiSelect.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeMultiSelect();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (e) => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isOpen) closeMultiSelect(); else openMultiSelect();
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeMultiSelect();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
||||||
|
}} else {{
|
||||||
|
setupMultiSelect();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
target_id_for_script_2,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
311
frontend/src/components/ui/select.rs
Normal file
311
frontend/src/components/ui/select.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
use icons::{Check, ChevronDown, ChevronUp};
|
||||||
|
use leptos::context::Provider;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::clx;
|
||||||
|
use strum::{AsRefStr, Display};
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||||
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
||||||
|
pub enum SelectPosition {
|
||||||
|
#[default]
|
||||||
|
Below,
|
||||||
|
Above,
|
||||||
|
}
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||||
|
clx! {SelectItem, 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"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectGroup(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = "Select options".into(), into)] aria_label: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!("group", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
||||||
|
{children()}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||||
|
let select_ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
||||||
|
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectOption(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
||||||
|
#[prop(optional, into)] value: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let value_for_check = value.clone();
|
||||||
|
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li
|
||||||
|
data-name="SelectOption"
|
||||||
|
class=merged_class
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected=move || is_selected().to_string()
|
||||||
|
data-select-option="true"
|
||||||
|
on:click=move |_| {
|
||||||
|
let val = value.clone();
|
||||||
|
ctx.value_signal.set(val.clone());
|
||||||
|
if let Some(on_change) = ctx.on_change {
|
||||||
|
on_change.run(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SelectContext {
|
||||||
|
target_id: String,
|
||||||
|
value_signal: RwSignal<Option<String>>,
|
||||||
|
on_change: Option<Callback<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Select(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] default_value: Option<String>,
|
||||||
|
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let select_target_id = use_random_id_for("select");
|
||||||
|
let value_signal = RwSignal::new(default_value);
|
||||||
|
|
||||||
|
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
||||||
|
|
||||||
|
let merged_class = tw_merge!("relative w-fit", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<div data-name="Select" class=merged_class>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
|
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||||
|
|
||||||
|
let button_class = tw_merge!(
|
||||||
|
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||||
|
&peer_class,
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="SelectTrigger"
|
||||||
|
class=button_class
|
||||||
|
id=button_id
|
||||||
|
tabindex="0"
|
||||||
|
data-select-trigger=ctx.target_id
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<ChevronDown class="text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||||
|
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
|
let target_id_for_script_2 = ctx.target_id.clone();
|
||||||
|
|
||||||
|
// Scroll indicator signals
|
||||||
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
data-name="SelectContent"
|
||||||
|
class=merged_class
|
||||||
|
on:selectclose=move |_: web_sys::CustomEvent| {
|
||||||
|
if let Some(cb) = on_close {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__select"
|
||||||
|
data-state="closed"
|
||||||
|
data-position=position.to_string()
|
||||||
|
style="pointer-events: none;"
|
||||||
|
on:scroll=move |ev| on_scroll.run(ev)
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-scroll-up="true"
|
||||||
|
class=move || {
|
||||||
|
let is_up: bool = can_scroll_up_signal.get();
|
||||||
|
if is_up {
|
||||||
|
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{children()}
|
||||||
|
<div
|
||||||
|
data-scroll-down="true"
|
||||||
|
class=move || {
|
||||||
|
let is_down: bool = can_scroll_down_signal.get();
|
||||||
|
if is_down {
|
||||||
|
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupSelect = () => {{
|
||||||
|
const select = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!select || !trigger) {{
|
||||||
|
setTimeout(setupSelect, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (select.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
select.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const updatePosition = () => {{
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||||
|
const spaceAbove = triggerRect.top;
|
||||||
|
|
||||||
|
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||||
|
select.setAttribute('data-position', 'Above');
|
||||||
|
}} else {{
|
||||||
|
select.setAttribute('data-position', 'Below');
|
||||||
|
}}
|
||||||
|
|
||||||
|
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const openSelect = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
|
updatePosition();
|
||||||
|
select.setAttribute('data-state', 'open');
|
||||||
|
select.style.pointerEvents = 'auto';
|
||||||
|
select.dispatchEvent(new Event('scroll'));
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeSelect = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
select.setAttribute('data-state', 'closed');
|
||||||
|
select.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
||||||
|
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeSelect();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (e) => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isOpen) closeSelect(); else openSelect();
|
||||||
|
}});
|
||||||
|
|
||||||
|
const options = select.querySelectorAll('[data-select-option]');
|
||||||
|
options.forEach(option => {{
|
||||||
|
option.addEventListener('click', () => closeSelect());
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeSelect();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupSelect);
|
||||||
|
}} else {{
|
||||||
|
setupSelect();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
target_id_for_script_2,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/separator.rs
Normal file
35
frontend/src/components/ui/separator.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
#[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" /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* 🧬 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,
|
||||||
|
}
|
||||||
@@ -49,21 +49,20 @@ pub fn SonnerTrigger(
|
|||||||
) -> 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
|
// Sonner Stacking Logic
|
||||||
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
|
|
||||||
let inverse_index = index;
|
let inverse_index = index;
|
||||||
let offset = inverse_index as f64 * 16.0;
|
let offset = inverse_index as f64 * 12.0;
|
||||||
let scale = 1.0 - (inverse_index as f64 * 0.05);
|
let scale = 1.0 - (inverse_index as f64 * 0.05);
|
||||||
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
|
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.15) };
|
||||||
|
|
||||||
let is_bottom = !position.to_string().contains("Top");
|
let is_bottom = position.to_string().contains("Bottom");
|
||||||
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
||||||
let translate_y = offset * y_direction;
|
let translate_y = offset * y_direction;
|
||||||
|
|
||||||
@@ -76,10 +75,10 @@ pub fn SonnerTrigger(
|
|||||||
);
|
);
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ pub fn SonnerTrigger(
|
|||||||
<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",
|
||||||
"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 is_bottom { "bottom-0" } else { "top-0" },
|
||||||
variant_classes
|
variant_classes
|
||||||
)
|
)
|
||||||
@@ -99,15 +98,14 @@ pub fn SonnerTrigger(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-0.5 overflow-hidden">
|
||||||
<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>
|
||||||
</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);
|
||||||
}
|
}
|
||||||
@@ -124,26 +122,29 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
let toasts = store.toasts;
|
let toasts = store.toasts;
|
||||||
let is_hovered = RwSignal::new(false);
|
let is_hovered = RwSignal::new(false);
|
||||||
|
|
||||||
let container_class = match position {
|
let (container_class, mobile_class) = match position {
|
||||||
SonnerPosition::TopLeft => "left-6 top-6 items-start",
|
SonnerPosition::TopLeft => ("left-6 top-6 items-start", "left-4 top-4"),
|
||||||
SonnerPosition::TopRight => "right-6 top-6 items-end",
|
SonnerPosition::TopRight => ("right-6 top-6 items-end", "right-4 top-4"),
|
||||||
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
|
SonnerPosition::TopCenter => ("left-1/2 -translate-x-1/2 top-6 items-center", "left-1/2 -translate-x-1/2 top-4"),
|
||||||
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
|
SonnerPosition::BottomCenter => ("left-1/2 -translate-x-1/2 bottom-6 items-center", "left-1/2 -translate-x-1/2 bottom-4"),
|
||||||
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
|
SonnerPosition::BottomLeft => ("left-6 bottom-6 items-start", "left-4 bottom-4"),
|
||||||
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
|
SonnerPosition::BottomRight => ("right-6 bottom-6 items-end", "right-4 bottom-4"),
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<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,
|
||||||
|
// Safe areas for mobile
|
||||||
|
"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,11 +152,10 @@ 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 || {
|
let expanded_style = move || {
|
||||||
if is_hovered.get() {
|
if is_hovered.get() {
|
||||||
let offset = index as f64 * 70.0;
|
let offset = index as f64 * 64.0;
|
||||||
let is_bottom = !position.to_string().contains("Top");
|
let is_bottom = position.to_string().contains("Bottom");
|
||||||
let y_dir = if is_bottom { -1.0 } else { 1.0 };
|
let y_dir = if is_bottom { -1.0 } else { 1.0 };
|
||||||
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
|
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
|
||||||
} else {
|
} else {
|
||||||
@@ -164,7 +164,7 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div style=expanded_style>
|
<div class="contents" style=expanded_style>
|
||||||
<SonnerTrigger
|
<SonnerTrigger
|
||||||
toast=toast
|
toast=toast
|
||||||
index=index
|
index=index
|
||||||
@@ -182,7 +182,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 +217,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