Compare commits
5 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a68fb630 | ||
|
|
155dd07193 | ||
|
|
e5f76fe548 | ||
|
|
5e098817f2 | ||
|
|
4dcbd8187e |
30
frontend/src/components/layout/footer.rs
Normal file
30
frontend/src/components/layout/footer.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::ui::separator::Separator;
|
||||
|
||||
#[component]
|
||||
pub fn Footer() -> impl IntoView {
|
||||
let year = chrono::Local::now().format("%Y").to_string();
|
||||
|
||||
view! {
|
||||
<footer class="mt-auto px-4 py-6 md:px-8">
|
||||
<Separator class="mb-6 opacity-50" />
|
||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<p class="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
||||
{format!("© {} VibeTorrent. Tüm hakları saklıdır.", year)}
|
||||
</p>
|
||||
<div class="flex items-center gap-4 text-sm font-medium text-muted-foreground">
|
||||
<a
|
||||
href="https://git.karatatar.com/admin/vibetorrent"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="underline underline-offset-4 hover:text-foreground transition-colors"
|
||||
>
|
||||
"Gitea"
|
||||
</a>
|
||||
<span class="size-1 rounded-full bg-muted-foreground/30" />
|
||||
<span class="text-[10px] tracking-widest uppercase opacity-70">"v3.0.0-beta"</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod sidebar;
|
||||
pub mod toolbar;
|
||||
pub mod footer;
|
||||
pub mod protected;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
use crate::components::layout::toolbar::Toolbar;
|
||||
use crate::components::layout::footer::Footer;
|
||||
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
|
||||
|
||||
#[component]
|
||||
@@ -18,8 +19,11 @@ pub fn Protected(children: Children) -> impl IntoView {
|
||||
<Toolbar />
|
||||
|
||||
// Ana İçerik
|
||||
<main class="flex-1 overflow-hidden relative bg-background">
|
||||
{children()}
|
||||
<main class="flex-1 overflow-y-auto relative bg-background flex flex-col">
|
||||
<div class="flex-1">
|
||||
{children()}
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</SidenavInset>
|
||||
</SidenavWrapper>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use std::collections::HashSet;
|
||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis};
|
||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
||||
use crate::store::{get_action_messages, show_toast};
|
||||
use crate::api;
|
||||
use shared::NotificationLevel;
|
||||
@@ -9,12 +9,13 @@ use crate::components::context_menu::TorrentContextMenu;
|
||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||
use crate::components::ui::data_table::*;
|
||||
use crate::components::ui::checkbox::Checkbox;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::button::{Button, ButtonVariant};
|
||||
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::*;
|
||||
use tailwind_fuse::tw_merge;
|
||||
|
||||
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||
("Name", "Name"),
|
||||
@@ -239,21 +240,40 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<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"
|
||||
<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())}
|
||||
<AlertDialogTitle class="text-destructive flex items-center gap-2">
|
||||
<Trash2 class="size-5" />
|
||||
"Toplu Silme Onayı"
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="pt-2">
|
||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
||||
<div class="mt-4 p-3 bg-muted/50 rounded-md text-xs border border-border italic">
|
||||
"Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogClose>"İptal"</AlertDialogClose>
|
||||
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")>
|
||||
"Sil"
|
||||
</Button>
|
||||
<AlertDialogFooter class="gap-2 sm:gap-0">
|
||||
<div class="flex flex-col sm:flex-row gap-2 w-full justify-end">
|
||||
<AlertDialogClose class="order-3 sm:order-1">"Vazgeç"</AlertDialogClose>
|
||||
<Button
|
||||
variant=ButtonVariant::Outline
|
||||
class="order-2 text-foreground"
|
||||
on:click=move |_| bulk_action("delete")
|
||||
>
|
||||
"Sadece Listeden Sil"
|
||||
</Button>
|
||||
<Button
|
||||
variant=ButtonVariant::Destructive
|
||||
class="order-1"
|
||||
on:click=move |_| bulk_action("delete_with_data")
|
||||
>
|
||||
"Verilerle Birlikte Sil"
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -262,28 +282,81 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
</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>
|
||||
// Mobile Sort Menu
|
||||
<div class="block md:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="w-[100px] h-9 gap-2 text-xs">
|
||||
<ListFilter class="size-4" />
|
||||
"Sırala"
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56">
|
||||
<DropdownMenuLabel>"Sıralama Ölçütü"</DropdownMenuLabel>
|
||||
<DropdownMenuGroup class="mt-2">
|
||||
{move || {
|
||||
let current_col = sort_col.0.get();
|
||||
let current_dir = sort_dir.0.get();
|
||||
|
||||
let sort_items = vec![
|
||||
(SortColumn::Name, "İsim"),
|
||||
(SortColumn::Size, "Boyut"),
|
||||
(SortColumn::Progress, "İlerleme"),
|
||||
(SortColumn::Status, "Durum"),
|
||||
(SortColumn::DownSpeed, "DL Hızı"),
|
||||
(SortColumn::UpSpeed, "UP Hızı"),
|
||||
(SortColumn::ETA, "Kalan Süre"),
|
||||
(SortColumn::AddedDate, "Tarih"),
|
||||
];
|
||||
|
||||
sort_items.into_iter().map(|(col, label)| {
|
||||
let is_active = current_col == col;
|
||||
view! {
|
||||
<DropdownMenuItem on:click=move |_| handle_sort(col)>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
{if is_active { view! { <Check class="size-4 text-primary" /> }.into_any() } else { view! { <div class="size-4" /> }.into_any() }}
|
||||
<span class=if is_active { "font-bold text-primary" } else { "" }>{label}</span>
|
||||
</div>
|
||||
{if is_active {
|
||||
match current_dir {
|
||||
SortDirection::Ascending => view! { <ArrowUp class="size-3 opacity-50" /> }.into_any(),
|
||||
SortDirection::Descending => view! { <ArrowDown class="size-3 opacity-50" /> }.into_any(),
|
||||
}
|
||||
} else { view! { "" }.into_any() }}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}.into_any()
|
||||
}).collect_view()
|
||||
}}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
// Desktop Columns Menu
|
||||
<div class="hidden md:flex">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +364,7 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
// Desktop Table View
|
||||
<DataTableWrapper class="hidden md:block h-full bg-card/50">
|
||||
// ... (Masaüstü tablosu aynı kalıyor)
|
||||
<div class="h-full overflow-auto">
|
||||
<DataTable>
|
||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||
@@ -410,7 +484,7 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
</DataTableWrapper>
|
||||
|
||||
// Mobile Card View
|
||||
<div class="block md:hidden h-full overflow-y-auto space-y-3 pb-20">
|
||||
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32">
|
||||
<Show
|
||||
when=move || !filtered_hashes.get().is_empty()
|
||||
fallback=move || view! {
|
||||
@@ -423,7 +497,25 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
move |hash| {
|
||||
view! { <TorrentCard hash=hash.clone() on_action=on_action.clone() /> }
|
||||
let h = hash.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
selected_hashes.with(|selected| selected.contains(&h))
|
||||
});
|
||||
let h_for_change = hash.clone();
|
||||
|
||||
view! {
|
||||
<TorrentCard
|
||||
hash=hash.clone()
|
||||
on_action=on_action.clone()
|
||||
is_selected=is_selected
|
||||
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>
|
||||
@@ -568,6 +660,8 @@ fn TorrentRow(
|
||||
fn TorrentCard(
|
||||
hash: String,
|
||||
on_action: Callback<(String, String)>,
|
||||
is_selected: Signal<bool>,
|
||||
on_select: Callback<bool>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
let h = hash.clone();
|
||||
@@ -582,48 +676,73 @@ fn TorrentCard(
|
||||
move || {
|
||||
let t = torrent.get().unwrap();
|
||||
let t_name = t.name.clone();
|
||||
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
||||
let status_badge_class = match t.status {
|
||||
shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800",
|
||||
shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800",
|
||||
shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800",
|
||||
shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800",
|
||||
_ => "bg-muted text-muted-foreground"
|
||||
};
|
||||
let h_for_menu = stored_hash.get_value();
|
||||
|
||||
view! {
|
||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"ring-2 ring-primary rounded-lg transition-all"
|
||||
} else {
|
||||
"transition-all"
|
||||
class=move || tw_merge!(
|
||||
"rounded-lg transition-all duration-200 border cursor-pointer select-none overflow-hidden active:scale-[0.98]",
|
||||
if is_selected.get() {
|
||||
"bg-primary/10 border-primary shadow-sm"
|
||||
} else {
|
||||
"bg-card border-border hover:border-primary/50"
|
||||
}
|
||||
)
|
||||
on:click=move |_| {
|
||||
let current = is_selected.get();
|
||||
on_select.run(!current);
|
||||
store.selected_torrent.set(Some(stored_hash.get_value()));
|
||||
}
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader class="p-3 pb-0">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody class="p-3 pt-2 gap-3 flex flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{format_bytes(t.size)}</span>
|
||||
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
|
||||
</div>
|
||||
<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 class={format!("shrink-0 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {}", status_badge_class)}>
|
||||
{format!("{:?}", t.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
|
||||
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
||||
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
|
||||
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between text-[10px] font-medium text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="opacity-70">"Boyut:"</span> {format_bytes(t.size)}
|
||||
</span>
|
||||
<span class="font-bold text-primary">{format!("{:.1}%", t.percent_complete)}</span>
|
||||
</div>
|
||||
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all duration-500 ease-out" style=format!("width: {}%", t.percent_complete)></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div class="grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] font-mono pt-2 border-t border-border/40">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"İndirme"</span>
|
||||
<span class="text-blue-600 dark:text-blue-400 font-bold">{format_speed(t.down_rate)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Gönderme"</span>
|
||||
<span class="text-green-600 dark:text-green-400 font-bold">{format_speed(t.up_rate)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Kalan Süre"</span>
|
||||
<span class="text-foreground font-medium">{format_duration(t.eta)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 items-end text-right">
|
||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Eklenme"</span>
|
||||
<span class="text-foreground/70">{format_date(t.added_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TorrentContextMenu>
|
||||
}.into_any()
|
||||
|
||||
Reference in New Issue
Block a user