Compare commits

...

12 Commits

Author SHA1 Message Date
spinline
b29f9f3cc2 fix: align AlertDialog structure with project standards using AlertDialogBody
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-12 23:30:42 +03:00
spinline
feede5c5b4 fix: resolve compilation type error and cleanup unused imports in app.rs
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-12 23:26:45 +03:00
spinline
c1306a32a9 fix: use data-preload='false' and revert SW strategy to resolve browser warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 44s
2026-02-12 23:23:40 +03:00
spinline
ed5fba4b46 fix: further refine alert dialog styling and button layout
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-12 23:22:07 +03:00
spinline
f149603ac8 fix: improve bulk delete dialog styling and responsive layout
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-12 23:17:40 +03:00
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
12 changed files with 176 additions and 568 deletions

View File

@@ -22,12 +22,32 @@ jobs:
git fetch --depth=1 origin ${{ gitea.sha }}
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
run: |
cd frontend
npm install
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
- name: Build Backend (MIPS)

View File

@@ -20,13 +20,12 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" data-no-preload />
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" data-preload="false" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="public/lock_scroll.js" />
<script src="/lock_scroll.js"></script>
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(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

@@ -1,13 +1,12 @@
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_location};
use leptos_router::hooks::use_navigate;
use crate::components::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode;
@@ -42,7 +41,6 @@ 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);
@@ -146,70 +144,43 @@ fn InnerApp() -> impl IntoView {
});
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>
<Show when=move || !is_loading.0.get() fallback=|| {
// Standard 1: Always show Dashboard Skeleton
view! {
<div class="flex h-screen bg-background text-foreground overflow-hidden">
// 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>
}.into_any()
}
// Main content skeleton
<div class="flex-1 flex flex-col min-w-0">
<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>

View File

@@ -1,31 +1,5 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
use leptos::prelude::*;
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
pub fn use_random_id() -> String {
format!("_{PREFIX}_{}", generate_hash())
pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
}
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

@@ -6,15 +6,25 @@ use crate::store::{get_action_messages, show_toast};
use crate::api;
use shared::NotificationLevel;
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::badge::{Badge, BadgeVariant};
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 crate::components::ui::alert_dialog::{
AlertDialog,
AlertDialogBody,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
use tailwind_fuse::tw_merge;
const ALL_COLUMNS: [(&str, &str); 8] = [
@@ -239,42 +249,46 @@ 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">
<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 cursor-pointer">
<Trash2 class="size-4" /> "Toplu Sil..."
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<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."
<AlertDialogContent class="sm:max-w-[425px]">
<AlertDialogBody>
<AlertDialogHeader class="space-y-3">
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
<Trash2 class="size-6" />
"Toplu Silme Onayı"
</AlertDialogTitle>
<AlertDialogDescription class="text-sm leading-relaxed">
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter class="mt-6">
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
<div class="flex flex-col sm:flex-row gap-2">
<Button
variant=ButtonVariant::Secondary
class="w-full sm:w-auto font-medium"
on:click=move |_| bulk_action("delete")
>
"Sadece Sil"
</Button>
<Button
variant=ButtonVariant::Destructive
class="w-full sm:w-auto font-bold"
on:click=move |_| bulk_action("delete_with_data")
>
"Verilerle Sil"
</Button>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<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>
</AlertDialogFooter>
</AlertDialogBody>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuGroup>
@@ -364,7 +378,6 @@ 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">
@@ -484,7 +497,7 @@ pub fn TorrentTable() -> impl IntoView {
</DataTableWrapper>
// Mobile Card View
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32">
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32 px-1">
<Show
when=move || !filtered_hashes.get().is_empty()
fallback=move || view! {
@@ -556,7 +569,6 @@ fn TorrentRow(
move || {
let t = torrent.get().unwrap();
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 selected = store.selected_torrent.get();
@@ -612,8 +624,18 @@ fn TorrentRow(
{move || visible_columns.get().contains("Status").then({
let status_text = format!("{:?}", t.status);
let color = status_color;
move || view! { <DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", color)}>{status_text.clone()}</DataTableCell> }
let 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,
};
move || view! {
<DataTableCell class="whitespace-nowrap">
<Badge variant=variant>{status_text.clone()}</Badge>
</DataTableCell>
}
}).into_any()}
{move || visible_columns.get().contains("DownSpeed").then({
@@ -676,12 +698,12 @@ 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_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();
@@ -707,9 +729,9 @@ fn TorrentCard(
<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={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)}>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
{format!("{:?}", t.status)}
</div>
</Badge>
</div>
<div class="space-y-1.5">

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" };
view! {
<script src="/hooks/lock_scroll.js"></script>
<script src="/lock_scroll.js"></script>
<div
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 alert_dialog;
pub mod badge;
pub mod button;
pub mod card;
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>,
#[prop(optional)] on_dismiss: Option<Callback<()>>,
) -> impl IntoView {
let _ = is_expanded; // Silence unused warning while keeping prop name intact for builder
let variant_classes = match toast.variant {
ToastType::Default => "bg-background text-foreground border-border",
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",