Compare commits

...

12 Commits

Author SHA1 Message Date
spinline
89ad42f24d feat: use constant dashboard skeleton for all loading states (Standard 1)
All checks were successful
Build MIPS Binary / build (push) Successful in 12m58s
2026-02-12 23:02:36 +03:00
spinline
bec804131b feat: add cargo and node_modules caching to Gitea workflow
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 22:59:37 +03:00
spinline
79979e3e09 fix: revert wasm-opt to 0 to resolve bulk memory validation error
All checks were successful
Build MIPS Binary / build (push) Successful in 5m23s
2026-02-12 22:48:53 +03:00
spinline
75efd877c4 chore: cleanup unused code, files, and improve code quality
Some checks failed
Build MIPS Binary / build (push) Failing after 1m53s
2026-02-12 22:44:38 +03:00
spinline
52c6f45a91 fix: resolve lock_scroll.js 404 and optimize Trunk preloading
Some checks failed
Build MIPS Binary / build (push) Failing after 1m54s
2026-02-12 22:42:06 +03:00
spinline
f9a8fbccfd feat: complete rewrite of torrent table with mobile sort, badge integration and clean UI
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 22:39:25 +03:00
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
16 changed files with 351 additions and 583 deletions

View File

@@ -22,12 +22,32 @@ jobs:
git fetch --depth=1 origin ${{ gitea.sha }} git fetch --depth=1 origin ${{ gitea.sha }}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache Node Modules
uses: actions/cache@v3
with:
path: frontend/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Build Frontend - name: Build Frontend
run: | run: |
cd frontend cd frontend
npm install npm install
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs' npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
trunk build --release trunk build --release
- name: Build Backend (MIPS) - name: Build Backend (MIPS)

View File

@@ -26,7 +26,6 @@
<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" /> <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 () {

View File

@@ -1,253 +0,0 @@
/**
* Scroll Lock Utility
* Handles locking and unlocking scroll for both window and all scrollable containers
* Similar to react-remove-scroll but in vanilla JavaScript
*/
(() => {
// Prevent multiple initializations
if (window.ScrollLock) {
return;
}
class ScrollLock {
constructor() {
this.locked = false;
this.scrollableElements = [];
this.scrollPositions = new Map();
this.originalStyles = new Map();
this.fixedElements = [];
}
/**
* Find all scrollable elements in the DOM (optimized)
* Uses more targeted selectors instead of querying all elements
*/
findScrollableElements() {
const scrollables = [];
// More targeted query - only look for elements with overflow properties
const candidates = document.querySelectorAll(
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
);
// Batch all style reads first to minimize reflows
const elementsToCheck = [];
for (const el of candidates) {
// Skip the element itself or if it's inside these containers
const dataName = el.getAttribute("data-name");
const isExcludedElement =
dataName === "ScrollArea" ||
dataName === "CommandList" ||
dataName === "SelectContent" ||
dataName === "MultiSelectContent" ||
dataName === "DropdownMenuContent" ||
dataName === "ContextMenuContent";
if (
el !== document.body &&
el !== document.documentElement &&
!isExcludedElement &&
!el.closest('[data-name="ScrollArea"]') &&
!el.closest('[data-name="CommandList"]') &&
!el.closest('[data-name="SelectContent"]') &&
!el.closest('[data-name="MultiSelectContent"]') &&
!el.closest('[data-name="DropdownMenuContent"]') &&
!el.closest('[data-name="ContextMenuContent"]')
) {
elementsToCheck.push(el);
}
}
// Now batch read all computed styles and dimensions
elementsToCheck.forEach((el) => {
const style = window.getComputedStyle(el);
const hasOverflow =
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll";
// Only check scrollHeight if overflow is set
if (hasOverflow && el.scrollHeight > el.clientHeight) {
scrollables.push(el);
}
});
return scrollables;
}
/**
* Lock scrolling on all scrollable elements (optimized)
* Batches all DOM reads before DOM writes to prevent forced reflows
*/
lock() {
if (this.locked) return;
this.locked = true;
// Find all scrollable elements
this.scrollableElements = this.findScrollableElements();
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
const windowScrollY = window.scrollY;
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
// Store window scroll position
this.scrollPositions.set("window", windowScrollY);
// Store original body styles
this.originalStyles.set("body", {
position: document.body.style.position,
top: document.body.style.top,
width: document.body.style.width,
overflow: document.body.style.overflow,
paddingRight: document.body.style.paddingRight,
});
// Read all fixed-position elements and their padding (only if we have scrollbar)
if (scrollbarWidth > 0) {
// Use more targeted query for fixed elements
const fixedCandidates = document.querySelectorAll(
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
);
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
const style = window.getComputedStyle(el);
return (
style.position === "fixed" &&
!el.closest('[data-name="DropdownMenuContent"]') &&
!el.closest('[data-name="MultiSelectContent"]') &&
!el.closest('[data-name="ContextMenuContent"]')
);
});
// Batch read all padding values
this.fixedElements.forEach((el) => {
const computedStyle = window.getComputedStyle(el);
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
this.originalStyles.set(el, {
paddingRight: el.style.paddingRight,
computedPadding: currentPadding,
});
});
}
// Read scrollable elements info
const scrollableInfo = this.scrollableElements.map((el) => {
const scrollTop = el.scrollTop;
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
const computedStyle = window.getComputedStyle(el);
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
this.scrollPositions.set(el, scrollTop);
this.originalStyles.set(el, {
overflow: el.style.overflow,
overflowY: el.style.overflowY,
paddingRight: el.style.paddingRight,
});
return { el, elementScrollbarWidth, currentPadding };
});
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
// Apply body lock
document.body.style.position = "fixed";
document.body.style.top = `-${windowScrollY}px`;
document.body.style.width = "100%";
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
// Apply padding compensation to fixed elements
this.fixedElements.forEach((el) => {
const stored = this.originalStyles.get(el);
if (stored) {
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
}
});
}
// Lock all scrollable containers
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
el.style.overflow = "hidden";
if (elementScrollbarWidth > 0) {
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
}
});
}
/**
* Unlock scrolling on all elements (optimized)
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
*/
unlock(delay = 0) {
if (!this.locked) return;
const performUnlock = () => {
// Restore body scroll
const bodyStyles = this.originalStyles.get("body");
if (bodyStyles) {
document.body.style.position = bodyStyles.position;
document.body.style.top = bodyStyles.top;
document.body.style.width = bodyStyles.width;
document.body.style.overflow = bodyStyles.overflow;
document.body.style.paddingRight = bodyStyles.paddingRight;
}
// Restore window scroll position
const windowScrollY = this.scrollPositions.get("window") || 0;
window.scrollTo(0, windowScrollY);
// Restore all scrollable containers
this.scrollableElements.forEach((el) => {
const originalStyles = this.originalStyles.get(el);
if (originalStyles) {
el.style.overflow = originalStyles.overflow;
el.style.overflowY = originalStyles.overflowY;
el.style.paddingRight = originalStyles.paddingRight;
}
// Restore scroll position
const scrollPosition = this.scrollPositions.get(el) || 0;
el.scrollTop = scrollPosition;
});
// Restore fixed-position elements padding
this.fixedElements.forEach((el) => {
const styles = this.originalStyles.get(el);
if (styles && styles.paddingRight !== undefined) {
el.style.paddingRight = styles.paddingRight;
}
});
// Clear storage
this.scrollableElements = [];
this.fixedElements = [];
this.scrollPositions.clear();
this.originalStyles.clear();
this.locked = false;
};
if (delay > 0) {
setTimeout(performUnlock, delay);
} else {
performUnlock();
}
}
/**
* Check if scrolling is currently locked
*/
isLocked() {
return this.locked;
}
}
// Export as singleton
window.ScrollLock = new ScrollLock();
})();

View File

@@ -131,51 +131,25 @@ fn InnerApp() -> impl IntoView {
view! { <Setup /> } view! { <Setup /> }
} /> } />
<Route path=leptos_router::path!("/") view=move || { <Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate(); let navigate = use_navigate();
Effect::new(move |_| { Effect::new(move |_| {
if !is_loading.0.get() { if !is_loading.0.get() {
if needs_setup.0.get() { if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup"); log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if !is_authenticated.0.get() { } else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login"); log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default()); navigate("/login", Default::default());
}
} }
} });
});
view! {
view! { <Show when=move || !is_loading.0.get() fallback=|| {
<Show when=move || !is_loading.0.get() fallback=move || { // Standard 1: Always show Dashboard Skeleton
let path = loc.pathname.get();
if path == "/login" {
// Login Skeleton
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="flex h-screen bg-background text-foreground overflow-hidden">
<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 // Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4"> <div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" /> <Skeleton class="h-8 w-3/4" />
@@ -189,7 +163,7 @@ fn InnerApp() -> impl IntoView {
</div> </div>
</div> </div>
// Main content skeleton // Main content skeleton
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col min-w-0">
<div class="border-b border-border p-4 flex items-center gap-4"> <div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" /> <Skeleton class="h-8 w-48" />
<Skeleton class="h-8 w-64" /> <Skeleton class="h-8 w-64" />
@@ -209,20 +183,19 @@ fn InnerApp() -> impl IntoView {
</div> </div>
</div> </div>
}.into_any() }.into_any()
} }>
}> <Show when=move || is_authenticated.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()> <Protected>
<Protected> <div class="flex flex-col h-full overflow-hidden">
<div class="flex flex-col h-full overflow-hidden"> <div class="flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden"> <TorrentTable />
<TorrentTable /> </div>
</div> </div>
</div> </Protected>
</Protected> </Show>
</Show> </Show>
</Show> }.into_any()
}.into_any() }/>
}/>
<Route path=leptos_router::path!("/settings") view=move || { <Route path=leptos_router::path!("/settings") view=move || {
let authenticated = is_authenticated.0.get(); let authenticated = is_authenticated.0.get();

View File

@@ -1,31 +1,5 @@
use std::collections::hash_map::DefaultHasher; use leptos::prelude::*;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-" pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
pub fn use_random_id() -> String {
format!("_{PREFIX}_{}", generate_hash())
} }
pub fn use_random_id_for(element: &str) -> String {
format!("{}_{PREFIX}_{}", element, generate_hash())
}
pub fn use_random_transition_name() -> String {
let random_id = use_random_id();
format!("view-transition-name: {random_id}")
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn generate_hash() -> u64 {
let mut hasher = DefaultHasher::new();
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
counter.hash(&mut hasher);
hasher.finish()
}

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 sidebar;
pub mod toolbar; pub mod toolbar;
pub mod footer;
pub mod protected; pub mod protected;

View File

@@ -1,6 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::components::layout::sidebar::Sidebar; use crate::components::layout::sidebar::Sidebar;
use crate::components::layout::toolbar::Toolbar; use crate::components::layout::toolbar::Toolbar;
use crate::components::layout::footer::Footer;
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset}; use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
#[component] #[component]
@@ -18,8 +19,11 @@ pub fn Protected(children: Children) -> impl IntoView {
<Toolbar /> <Toolbar />
// Ana İçerik // Ana İçerik
<main class="flex-1 overflow-hidden relative bg-background"> <main class="flex-1 overflow-y-auto relative bg-background flex flex-col">
{children()} <div class="flex-1">
{children()}
</div>
<Footer />
</main> </main>
</SidenavInset> </SidenavInset>
</SidenavWrapper> </SidenavWrapper>

View File

@@ -1,20 +1,21 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use std::collections::HashSet; 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::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::data_table::*; use crate::components::ui::data_table::*;
use crate::components::ui::checkbox::Checkbox; use crate::components::ui::checkbox::Checkbox;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize}; use crate::components::ui::badge::{Badge, BadgeVariant};
use crate::components::ui::button::{Button, ButtonVariant};
use crate::components::ui::empty::*; use crate::components::ui::empty::*;
use crate::components::ui::input::Input; use crate::components::ui::input::Input;
use crate::components::ui::multi_select::*; use crate::components::ui::multi_select::*;
use crate::components::ui::dropdown_menu::*; use crate::components::ui::dropdown_menu::*;
use crate::components::ui::alert_dialog::*; use crate::components::ui::alert_dialog::*;
use tailwind_fuse::tw_merge;
const ALL_COLUMNS: [(&str, &str); 8] = [ const ALL_COLUMNS: [(&str, &str); 8] = [
("Name", "Name"), ("Name", "Name"),
@@ -239,21 +240,40 @@ pub fn TorrentTable() -> impl IntoView {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger class="w-full text-left"> <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"> <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> </div>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle> <AlertDialogTitle class="text-destructive flex items-center gap-2">
<AlertDialogDescription> <Trash2 class="size-5" />
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())} "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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter class="gap-2 sm:gap-0">
<AlertDialogClose>"İptal"</AlertDialogClose> <div class="flex flex-col sm:flex-row gap-2 w-full justify-end">
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")> <AlertDialogClose class="order-3 sm:order-1">"Vazgeç"</AlertDialogClose>
"Sil" <Button
</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> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@@ -262,28 +282,81 @@ pub fn TorrentTable() -> impl IntoView {
</DropdownMenu> </DropdownMenu>
</Show> </Show>
<MultiSelect values=visible_columns> // Mobile Sort Menu
<MultiSelectTrigger class="w-[140px] h-9"> <div class="block md:hidden">
<div class="flex items-center gap-2 text-xs"> <DropdownMenu>
<Settings2 class="size-4" /> <DropdownMenuTrigger class="w-[100px] h-9 gap-2 text-xs">
"Sütunlar" <ListFilter class="size-4" />
</div> "Sırala"
</MultiSelectTrigger> </DropdownMenuTrigger>
<MultiSelectContent> <DropdownMenuContent class="w-56">
<MultiSelectGroup> <DropdownMenuLabel>"Sıralama Ölçütü"</DropdownMenuLabel>
{ALL_COLUMNS.into_iter().map(|(id, label)| { <DropdownMenuGroup class="mt-2">
let id_val = id.to_string(); {move || {
view! { let current_col = sort_col.0.get();
<MultiSelectItem> let current_dir = sort_dir.0.get();
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
{label} let sort_items = vec![
</MultiSelectOption> (SortColumn::Name, "İsim"),
</MultiSelectItem> (SortColumn::Size, "Boyut"),
}.into_any() (SortColumn::Progress, "İlerleme"),
}).collect_view()} (SortColumn::Status, "Durum"),
</MultiSelectGroup> (SortColumn::DownSpeed, "DL Hızı"),
</MultiSelectContent> (SortColumn::UpSpeed, "UP Hızı"),
</MultiSelect> (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>
</div> </div>
@@ -410,7 +483,7 @@ pub fn TorrentTable() -> impl IntoView {
</DataTableWrapper> </DataTableWrapper>
// Mobile Card View // 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 px-1">
<Show <Show
when=move || !filtered_hashes.get().is_empty() when=move || !filtered_hashes.get().is_empty()
fallback=move || view! { fallback=move || view! {
@@ -423,7 +496,25 @@ pub fn TorrentTable() -> impl IntoView {
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={ <For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone(); let on_action = on_action.clone();
move |hash| { 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> </Show>
@@ -464,7 +555,6 @@ fn TorrentRow(
move || { move || {
let t = torrent.get().unwrap(); let t = torrent.get().unwrap();
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 is_active_selection = Memo::new(move |_| { let is_active_selection = Memo::new(move |_| {
let selected = store.selected_torrent.get(); let selected = store.selected_torrent.get();
@@ -520,8 +610,18 @@ fn TorrentRow(
{move || visible_columns.get().contains("Status").then({ {move || visible_columns.get().contains("Status").then({
let status_text = format!("{:?}", t.status); let status_text = format!("{:?}", t.status);
let color = status_color; let variant = match t.status {
move || view! { <DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", color)}>{status_text.clone()}</DataTableCell> } shared::TorrentStatus::Seeding => BadgeVariant::Success,
shared::TorrentStatus::Downloading => BadgeVariant::Info,
shared::TorrentStatus::Paused => BadgeVariant::Warning,
shared::TorrentStatus::Error => BadgeVariant::Destructive,
_ => BadgeVariant::Secondary,
};
move || view! {
<DataTableCell class="whitespace-nowrap">
<Badge variant=variant>{status_text.clone()}</Badge>
</DataTableCell>
}
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("DownSpeed").then({ {move || visible_columns.get().contains("DownSpeed").then({
@@ -568,6 +668,8 @@ fn TorrentRow(
fn TorrentCard( fn TorrentCard(
hash: String, hash: String,
on_action: Callback<(String, String)>, on_action: Callback<(String, String)>,
is_selected: Signal<bool>,
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();
@@ -582,48 +684,73 @@ fn TorrentCard(
move || { move || {
let t = torrent.get().unwrap(); let t = torrent.get().unwrap();
let t_name = t.name.clone(); 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_variant = match t.status {
shared::TorrentStatus::Seeding => BadgeVariant::Success,
shared::TorrentStatus::Downloading => BadgeVariant::Info,
shared::TorrentStatus::Paused => BadgeVariant::Warning,
shared::TorrentStatus::Error => BadgeVariant::Destructive,
_ => BadgeVariant::Secondary
};
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()>
<div <div
class=move || { class=move || tw_merge!(
let selected = store.selected_torrent.get(); "rounded-lg transition-all duration-200 border cursor-pointer select-none overflow-hidden active:scale-[0.98]",
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str()); if is_selected.get() {
if is_selected { "bg-primary/10 border-primary shadow-sm"
"ring-2 ring-primary rounded-lg transition-all" } else {
} else { "bg-card border-border hover:border-primary/50"
"transition-all"
} }
)
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"> <div class="p-4 space-y-3">
<CardHeader class="p-3 pb-0"> <div class="flex justify-between items-start gap-3">
<div class="flex justify-between items-start gap-2"> <div class="flex-1 min-w-0">
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle> <h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
<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> </div>
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden"> <Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div> {format!("{:?}", t.status)}
</Badge>
</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>
</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="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 text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div> <div class="flex flex-col gap-0.5">
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div> <span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"İndirme"</span>
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div> <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>
</CardBody> </div>
</Card>
</div> </div>
</TorrentContextMenu> </TorrentContextMenu>
}.into_any() }.into_any()

View File

@@ -0,0 +1,43 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum BadgeVariant {
#[default]
Default,
Secondary,
Outline,
Destructive,
Success,
Warning,
Info,
}
#[component]
pub fn Badge(
children: Children,
#[prop(optional, default = BadgeVariant::Default)] variant: BadgeVariant,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let variant_classes = match variant {
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
BadgeVariant::Outline => "text-foreground",
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
BadgeVariant::Info => "border-transparent bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
};
let class = tw_merge!(
"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",
variant_classes,
class
);
view! {
<div class=class>
{children()}
</div>
}
}

View File

@@ -93,7 +93,7 @@ pub fn DialogContent(
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" }; let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
view! { view! {
<script src="/hooks/lock_scroll.js"></script> <script src="/lock_scroll.js"></script>
<div <div
data-name=backdrop_data_name data-name=backdrop_data_name

View File

@@ -1,25 +0,0 @@
use leptos::prelude::*;
use leptos_ui::clx;
mod components {
use super::*;
clx! {Draggable, div, "flex flex-col gap-4 w-full max-w-4xl"}
clx! {DraggableZone, div, "dragabble__container", "bg-neutral-600 p-4 mt-4"}
// TODO. ItemRoot (needs `draggable` as clx attribute).
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn DraggableItem(#[prop(into)] text: String) -> impl IntoView {
view! {
<div class="p-4 border cursor-move border-input bg-card draggable" draggable="true">
{text}
</div>
}
}

View File

@@ -1,5 +1,6 @@
pub mod accordion; pub mod accordion;
pub mod alert_dialog; pub mod alert_dialog;
pub mod badge;
pub mod button; pub mod button;
pub mod card; pub mod card;
pub mod checkbox; pub mod checkbox;

View File

@@ -1,145 +0,0 @@
use leptos::prelude::*;
use tw_merge::*;
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum ToastType {
#[default]
Default,
Success,
Error,
Warning,
Info,
Loading,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SonnerPosition {
TopLeft,
TopCenter,
TopRight,
#[default]
BottomRight,
BottomCenter,
BottomLeft,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SonnerDirection {
TopDown,
#[default]
BottomUp,
}
#[component]
pub fn SonnerTrigger(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = ToastType::default())] variant: ToastType,
#[prop(into)] title: String,
#[prop(into)] description: String,
#[prop(into, optional)] position: String,
) -> impl IntoView {
let variant_classes = match variant {
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
ToastType::Success => "bg-success text-success-foreground hover:bg-success/90",
ToastType::Error => "bg-destructive text-white shadow-xs hover:bg-destructive/90 dark:bg-destructive/60",
ToastType::Warning => "bg-warning text-warning-foreground hover:bg-warning/90",
ToastType::Info => "bg-info text-info-foreground shadow-xs hover:bg-info/90",
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
};
let merged_class = tw_merge!(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] w-fit cursor-pointer h-9 px-4 py-2",
variant_classes,
class
);
// Only set position attribute if not empty
let position_attr = if position.is_empty() { None } else { Some(position) };
view! {
<button
class=merged_class
data-name="SonnerTrigger"
data-variant=variant.to_string()
data-toast-title=title
data-toast-description=description
data-toast-position=position_attr
>
{children()}
</button>
}
}
#[component]
pub fn SonnerContainer(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
) -> impl IntoView {
let merged_class = tw_merge!("toast__container fixed z-50", class);
view! {
<div class=merged_class data-position=position.to_string()>
{children()}
</div>
}
}
#[component]
pub fn SonnerList(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
#[prop(into, default = "false".to_string())] expanded: String,
#[prop(into, optional)] style: String,
) -> impl IntoView {
// pointer-events-none: container doesn't block clicks when empty
// [&>*]:pointer-events-auto: toast items still receive clicks
let merged_class = tw_merge!(
"flex relative flex-col opacity-100 gap-[15px] h-[100px] w-[400px] pointer-events-none [&>*]:pointer-events-auto",
class
);
view! {
<ol
class=merged_class
data-name="SonnerList"
data-sonner-toaster="true"
data-sonner-theme="light"
data-position=position.to_string()
data-expanded=expanded
data-direction=direction.to_string()
style=style
>
{children()}
</ol>
}
}
#[component]
pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
// Auto-derive direction from position
let direction = match position {
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
_ => SonnerDirection::BottomUp,
};
let container_class = match position {
SonnerPosition::TopLeft => "left-6 top-6",
SonnerPosition::TopRight => "right-6 top-6",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6",
SonnerPosition::BottomLeft => "left-6 bottom-6",
SonnerPosition::BottomRight => "right-6 bottom-6",
};
view! {
<SonnerContainer class=container_class position=position>
<SonnerList position=position direction=direction>
""
</SonnerList>
</SonnerContainer>
}
}

View File

@@ -49,6 +49,7 @@ pub fn SonnerTrigger(
is_expanded: Signal<bool>, is_expanded: Signal<bool>,
#[prop(optional)] on_dismiss: Option<Callback<()>>, #[prop(optional)] on_dismiss: Option<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
let _ = is_expanded; // Silence unused warning while keeping prop name intact for builder
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-green-500", ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",

View File

@@ -88,7 +88,25 @@ self.addEventListener("fetch", (event) => {
return; 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( event.respondWith(
caches.match(event.request).then((response) => { caches.match(event.request).then((response) => {
return ( return (