Compare commits

..

11 Commits

Author SHA1 Message Date
spinline
4a0ebf0cb1 fix: change WASM caching strategy to Network-First to resolve preload warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-12 22:31:53 +03:00
spinline
e5a68fb630 feat: add minimal footer to protected layout
All checks were successful
Build MIPS Binary / build (push) Successful in 5m23s
2026-02-12 22:23:20 +03:00
spinline
155dd07193 fix: resolve tw_merge macro and MultiSelect prop errors in torrent table
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 22:20:56 +03:00
spinline
e5f76fe548 feat: add mobile-optimized sort dropdown and hide column selector on mobile
Some checks failed
Build MIPS Binary / build (push) Failing after 1m25s
2026-02-12 22:18:09 +03:00
spinline
5e098817f2 feat: optimize mobile UI with better card selection, spacing and hidden column selector
Some checks failed
Build MIPS Binary / build (push) Failing after 1m23s
2026-02-12 22:15:25 +03:00
spinline
4dcbd8187e feat: enhance bulk delete dialog with 'delete with data' option
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-12 22:08:14 +03:00
spinline
6c0c0a0919 fix: resolve syntax error in app.rs and cleanup duplicate blocks
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-12 22:02:14 +03:00
spinline
3158a11229 feat: add responsive mobile card view for torrents
Some checks failed
Build MIPS Binary / build (push) Failing after 1m19s
2026-02-12 21:59:36 +03:00
spinline
45f5d1b678 feat: implement contextual skeletons for login and dashboard
Some checks failed
Build MIPS Binary / build (push) Failing after 1m25s
2026-02-12 21:55:14 +03:00
spinline
c8e3caa4fc feat: implement and use standardized Skeleton component
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:49:58 +03:00
spinline
98555f16ca fix: refactor toast to use Flexbox layout to prevent overlapping
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:47:14 +03:00
9 changed files with 438 additions and 205 deletions

View File

@@ -1,11 +1,13 @@
use crate::components::layout::protected::Protected;
use crate::components::ui::skeleton::Skeleton;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate;
use leptos_router::hooks::{use_navigate, use_location};
use crate::components::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode;
@@ -30,7 +32,9 @@ pub fn App() -> impl IntoView {
view! {
<Toaster />
<InnerApp />
<Router>
<InnerApp />
</Router>
}
}
@@ -38,6 +42,7 @@ pub fn App() -> impl IntoView {
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>();
let loc = use_location();
let is_loading = signal(true);
let is_authenticated = signal(false);
@@ -97,119 +102,148 @@ fn InnerApp() -> impl IntoView {
view! {
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex h-screen bg-background">
// Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4">
<div class="h-8 w-3/4 animate-pulse rounded-md bg-muted" />
<div class="space-y-2">
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-4/5 animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-3/5 animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
<div class="border-b border-border p-4 flex items-center gap-4">
<div class="h-8 w-48 animate-pulse rounded-md bg-muted" />
<div class="h-8 w-64 animate-pulse rounded-md bg-muted" />
<div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div>
</div>
<div class="flex-1 p-4 space-y-3">
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" />
</div>
<div class="border-t border-border p-3">
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
</div>
</div>
</div>
}.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
</div>
</Protected>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</Router>
});
view! {
<Show when=move || !is_loading.0.get() fallback=move || {
let path = loc.pathname.get();
if path == "/login" {
// Login Skeleton
view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<Card class="w-full max-w-sm shadow-lg border-none">
<CardHeader class="pb-2 items-center space-y-4">
<Skeleton class="w-12 h-12 rounded-xl" />
<Skeleton class="h-8 w-32" />
<Skeleton class="h-4 w-48" />
</CardHeader>
<CardContent class="pt-4 space-y-6">
<div class="space-y-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-10 w-full" />
</div>
<div class="space-y-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-10 w-full" />
</div>
<Skeleton class="h-10 w-full rounded-md mt-4" />
</CardContent>
</Card>
</div>
}.into_any()
} else {
// Dashboard Skeleton
view! {
<div class="flex h-screen bg-background">
// Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" />
<div class="space-y-2">
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-4/5" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-3/5" />
<Skeleton class="h-6 w-full" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
<div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-8 w-64" />
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
</div>
<div class="flex-1 p-4 space-y-3">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-3/4" />
</div>
<div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" />
</div>
</div>
</div>
}.into_any()
}
}>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
</div>
</Protected>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
let authenticated = is_authenticated.0.get();
Effect::new(move |_| {
if !authenticated {
let navigate = use_navigate();
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || authenticated fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</div>
}
}

View 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>
}
}

View File

@@ -1,3 +1,4 @@
pub mod sidebar;
pub mod toolbar;
pub mod footer;
pub mod protected;

View File

@@ -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>

View File

@@ -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,34 +282,89 @@ 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>
// --- MAIN TABLE ---
// --- MAIN CONTENT ---
<div class="flex-1 min-h-0 overflow-hidden">
<DataTableWrapper class="h-full bg-card/50">
// 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">
@@ -305,49 +380,49 @@ pub fn TorrentTable() -> impl IntoView {
</DataTableHead>
{move || visible_columns.get().contains("Name").then(|| view! {
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
<DataTableHead class="cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" 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)>
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" 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)>
<DataTableHead class="w-48 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" 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)>
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" 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)>
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground 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)>
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground 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)>
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground 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)>
<DataTableHead class="w-32 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground 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()}
@@ -407,6 +482,44 @@ pub fn TorrentTable() -> impl IntoView {
</DataTable>
</div>
</DataTableWrapper>
// Mobile Card View
<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! {
<div class="flex flex-col items-center justify-center h-64 opacity-50 text-muted-foreground">
<Inbox class="size-12 mb-2" />
<p>"Torrent Bulunamadı"</p>
</div>
}.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! {
<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>
</div>
</div>
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
@@ -547,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();
@@ -561,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()

View File

@@ -14,6 +14,7 @@ pub mod select;
pub mod separator;
pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod svg_icon;
pub mod table;
pub mod theme_toggle;

View File

@@ -0,0 +1,13 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn Skeleton(
#[prop(optional, into)] class: String,
) -> impl IntoView {
let class = tw_merge!(
"animate-pulse rounded-md bg-muted",
class
);
view! { <div class=class /> }
}

View File

@@ -66,18 +66,11 @@ pub fn SonnerTrigger(
_ => "bg-primary",
};
// List Layout Logic (No stacking/overlapping)
// Simplified Style (No manual translateY needed with Flexbox)
let style = move || {
let is_bottom = position.to_string().contains("Bottom");
let y_direction = if is_bottom { -1.0 } else { 1.0 };
// Dynamic Y position based on index (index 0 is the newest/bottom-most in the view)
let y = index as f64 * 72.0; // Height (approx 64px) + Gap (8px)
format!(
"z-index: {}; transform: translateY({}px); opacity: 1; transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;",
total - index,
y * y_direction
"z-index: {}; opacity: 1; transition: all 0.3s ease;",
total - index
)
};
@@ -105,9 +98,8 @@ pub fn SonnerTrigger(
view! {
<div
class=move || tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto overflow-hidden",
"relative transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-xl bg-card",
if position.to_string().contains("Bottom") { "bottom-0" } else { "top-0" },
variant_classes,
animation_class()
)
@@ -150,7 +142,8 @@ pub fn provide_toaster() {
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
let store = use_context::<ToasterStore>().expect("Toaster context not found");
let toasts = store.toasts;
let is_hovered = RwSignal::new(false);
let is_bottom = position.to_string().contains("Bottom");
let container_class = match position {
SonnerPosition::TopLeft => "left-6 top-6 items-start",
@@ -175,17 +168,16 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
</style>
<div
class=tw_merge!(
"fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]",
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
if is_bottom { "flex-col-reverse" } else { "flex-col" },
container_class,
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
)
on:mouseenter=move |_| is_hovered.set(true)
on:mouseleave=move |_| is_hovered.set(false)
>
<For
each=move || {
let list = toasts.get();
list.into_iter().rev().enumerate().collect::<Vec<_>>()
list.into_iter().enumerate().collect::<Vec<_>>()
}
key=|(_, toast)| toast.id
children=move |(index, toast)| {
@@ -199,7 +191,7 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
index=index
total=total
position=position
is_expanded=is_hovered.into()
is_expanded=Signal::derive(move || true)
on_dismiss=Callback::new(move |_| {
is_exiting.set(true);
leptos::task::spawn_local(async move {

View File

@@ -88,7 +88,25 @@ self.addEventListener("fetch", (event) => {
return;
}
// Cache-first strategy for static assets (JS, CSS, Images)
// Special strategy for WASM and Main JS to prevent Preload warnings
if (url.pathname.endsWith(".wasm") || (url.pathname.endsWith(".js") && url.pathname.includes("frontend-"))) {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
}),
);
return;
}
// Cache-first strategy for other static assets (CSS, Images, etc.)
event.respondWith(
caches.match(event.request).then((response) => {
return (