Compare commits

...

21 Commits

Author SHA1 Message Date
spinline
c8139f9338 chore: comprehensive cleanup of unused imports, dead code and compiler warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-12 23:43:33 +03:00
spinline
a3735d0931 fix: resolve compilation errors related to JsCast and AlertDialogTrigger attributes
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-12 23:39:17 +03:00
spinline
55f00729ee fix: relocate AlertDialog outside of DropdownMenu to ensure proper centering
Some checks failed
Build MIPS Binary / build (push) Failing after 30s
2026-02-12 23:37:36 +03:00
spinline
275f4a91b2 fix: change href to src in Trunk script tag to resolve build error
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-12 23:34:06 +03:00
spinline
025a0c4a57 fix: use script tag for Trunk rust asset to resolve preload warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 25s
2026-02-12 23:32:37 +03:00
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
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
21 changed files with 394 additions and 716 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

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

@@ -1,13 +1,12 @@
use crate::components::layout::protected::Protected; use crate::components::layout::protected::Protected;
use crate::components::ui::skeleton::Skeleton; use crate::components::ui::skeleton::Skeleton;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route}; 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::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode; use crate::components::hooks::use_theme_mode::ThemeMode;
@@ -42,7 +41,6 @@ pub fn App() -> impl IntoView {
fn InnerApp() -> impl IntoView { fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>(); let store = use_context::<crate::store::TorrentStore>();
let loc = use_location();
let is_loading = signal(true); let is_loading = signal(true);
let is_authenticated = signal(false); let is_authenticated = signal(false);
@@ -146,70 +144,43 @@ fn InnerApp() -> impl IntoView {
}); });
view! { view! {
<Show when=move || !is_loading.0.get() fallback=move || { <Show when=move || !is_loading.0.get() fallback=|| {
let path = loc.pathname.get(); // Standard 1: Always show Dashboard Skeleton
if path == "/login" { view! {
// Login Skeleton <div class="flex h-screen bg-background text-foreground overflow-hidden">
view! { // Sidebar skeleton
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="w-56 border-r border-border p-4 space-y-4">
<Card class="w-full max-w-sm shadow-lg border-none"> <Skeleton class="h-8 w-3/4" />
<CardHeader class="pb-2 items-center space-y-4"> <div class="space-y-2">
<Skeleton class="w-12 h-12 rounded-xl" /> <Skeleton class="h-6 w-full" />
<Skeleton class="h-8 w-32" /> <Skeleton class="h-6 w-full" />
<Skeleton class="h-4 w-48" /> <Skeleton class="h-6 w-4/5" />
</CardHeader> <Skeleton class="h-6 w-full" />
<CardContent class="pt-4 space-y-6"> <Skeleton class="h-6 w-3/5" />
<div class="space-y-2"> <Skeleton class="h-6 w-full" />
<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>
</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=|| ()> <Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected> <Protected>

View File

@@ -1,31 +1,3 @@
use std::collections::hash_map::DefaultHasher; pub fn use_random_id_for(prefix: &str) -> String {
use std::hash::{Hash, Hasher}; format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
use std::sync::atomic::{AtomicUsize, Ordering};
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(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

@@ -8,26 +8,15 @@ pub struct ThemeMode {
const LOCALSTORAGE_KEY: &str = "darkmode"; const LOCALSTORAGE_KEY: &str = "darkmode";
/// Hook to access the dark mode context
///
/// Returns the ThemeMode instance from context for easy access
pub fn use_theme_mode() -> ThemeMode { pub fn use_theme_mode() -> ThemeMode {
expect_context::<ThemeMode>() expect_context::<ThemeMode>()
} }
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
impl ThemeMode { impl ThemeMode {
#[must_use]
/// Initializes a new ThemeMode instance.
pub fn init() -> Self { pub fn init() -> Self {
let theme_mode = Self { state: RwSignal::new(false) }; let theme_mode = Self { state: RwSignal::new(false) };
provide_context(theme_mode); provide_context(theme_mode);
// Use Effect to handle browser-only initialization
Effect::new(move |_| { Effect::new(move |_| {
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode()); let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
theme_mode.state.set(initial); theme_mode.state.set(initial);
@@ -43,45 +32,14 @@ impl ThemeMode {
}); });
} }
pub fn set_dark(&self) {
self.set(true);
}
pub fn set_light(&self) {
self.set(false);
}
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
pub fn set(&self, dark: bool) {
self.state.set(dark);
Self::set_storage_state(dark);
}
#[must_use]
pub fn get(&self) -> bool { pub fn get(&self) -> bool {
self.state.get() self.state.get()
} }
#[must_use]
pub fn is_dark(&self) -> bool {
self.state.get()
}
#[must_use]
pub fn is_light(&self) -> bool {
!self.state.get()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
/// Retrieves the local storage object, if available.
fn get_storage() -> Option<Storage> { fn get_storage() -> Option<Storage> {
window().local_storage().ok().flatten() window().local_storage().ok().flatten()
} }
/// Retrieves the dark mode state from local storage, if available.
fn get_storage_state() -> Option<bool> { fn get_storage_state() -> Option<bool> {
Self::get_storage() Self::get_storage()
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok()) .and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
@@ -89,7 +47,6 @@ impl ThemeMode {
.and_then(|entry| entry.parse::<bool>().ok()) .and_then(|entry| entry.parse::<bool>().ok())
} }
/// Checks whether the user's system prefers dark mode based on media queries.
fn prefers_dark_mode() -> bool { fn prefers_dark_mode() -> bool {
window() window()
.match_media("(prefers-color-scheme: dark)") .match_media("(prefers-color-scheme: dark)")
@@ -99,10 +56,9 @@ impl ThemeMode {
.unwrap_or_default() .unwrap_or_default()
} }
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) { fn set_storage_state(state: bool) {
if let Some(storage) = Self::get_storage() { if let Some(storage) = Self::get_storage() {
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok(); let _ = storage.set(LOCALSTORAGE_KEY, state.to_string().as_str());
} }
} }
} }

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,32 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use wasm_bindgen::JsCast;
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::{
AlertDialog,
AlertDialogBody,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
use tailwind_fuse::tw_merge;
const ALL_COLUMNS: [(&str, &str); 8] = [ const ALL_COLUMNS: [(&str, &str); 8] = [
("Name", "Name"), ("Name", "Name"),
@@ -219,90 +231,155 @@ pub fn TorrentTable() -> impl IntoView {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when=move || has_selection.get()> <Show when=move || has_selection.get()>
<DropdownMenu> <div class="flex items-center gap-2">
<DropdownMenuTrigger class="w-[140px] h-9 gap-2"> <DropdownMenu>
<Ellipsis class="size-4" /> <DropdownMenuTrigger class="w-[140px] h-9 gap-2">
{move || format!("Toplu İşlem ({})", selected_count.get())} <Ellipsis class="size-4" />
</DropdownMenuTrigger> {move || format!("Toplu İşlem ({})", selected_count.get())}
<DropdownMenuContent class="w-48"> </DropdownMenuTrigger>
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel> <DropdownMenuContent class="w-48">
<DropdownMenuGroup class="mt-2"> <DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
<DropdownMenuItem on:click=move |_| bulk_action("start")> <DropdownMenuGroup class="mt-2">
<Play class="mr-2 size-4" /> "Başlat" <DropdownMenuItem on:click=move |_| bulk_action("start")>
</DropdownMenuItem> <Play class="mr-2 size-4" /> "Başlat"
<DropdownMenuItem on:click=move |_| bulk_action("stop")> </DropdownMenuItem>
<Square class="mr-2 size-4" /> "Durdur" <DropdownMenuItem on:click=move |_| bulk_action("stop")>
</DropdownMenuItem> <Square class="mr-2 size-4" /> "Durdur"
</DropdownMenuItem>
<div class="my-1 h-px bg-border" /> <div class="my-1 h-px bg-border" />
<AlertDialog> // Trigger the hidden AlertDialog from this menu item
<AlertDialogTrigger class="w-full text-left"> <DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
<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"> if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
<Trash2 class="size-4" /> "Toplu Sil..." let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
</div> }
</AlertDialogTrigger> }>
<AlertDialogContent> <Trash2 class="mr-2 size-4" /> "Toplu Sil..."
<AlertDialogHeader> </DropdownMenuItem>
<AlertDialogTitle class="text-destructive flex items-center gap-2"> </DropdownMenuGroup>
<Trash2 class="size-5" /> </DropdownMenuContent>
"Toplu Silme Onayı" </DropdownMenu>
</AlertDialogTitle>
<AlertDialogDescription class="pt-2"> // Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())} <AlertDialog>
<div class="mt-4 p-3 bg-muted/50 rounded-md text-xs border border-border italic"> <AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
"Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır." <AlertDialogContent class="sm:max-w-[425px]">
</div> <AlertDialogBody>
</AlertDialogDescription> <AlertDialogHeader class="space-y-3">
</AlertDialogHeader> <AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
<AlertDialogFooter class="gap-2 sm:gap-0"> <Trash2 class="size-6" />
<div class="flex flex-col sm:flex-row gap-2 w-full justify-end"> "Toplu Silme Onayı"
<AlertDialogClose class="order-3 sm:order-1">"Vazgeç"</AlertDialogClose> </AlertDialogTitle>
<AlertDialogDescription class="text-sm leading-relaxed text-left">
{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 <Button
variant=ButtonVariant::Outline variant=ButtonVariant::Secondary
class="order-2 text-foreground" class="w-full sm:w-auto font-medium"
on:click=move |_| bulk_action("delete") on:click=move |_| bulk_action("delete")
> >
"Sadece Listeden Sil" "Sadece Sil"
</Button> </Button>
<Button <Button
variant=ButtonVariant::Destructive variant=ButtonVariant::Destructive
class="order-1" class="w-full sm:w-auto font-bold"
on:click=move |_| bulk_action("delete_with_data") on:click=move |_| bulk_action("delete_with_data")
> >
"Verilerle Birlikte Sil" "Verilerle Sil"
</Button> </Button>
</div> </div>
</AlertDialogFooter> </div>
</AlertDialogContent> </AlertDialogFooter>
</AlertDialog> </AlertDialogBody>
</AlertDialogContent>
</AlertDialog>
</div>
</Show>
// 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> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</Show> </div>
<MultiSelect values=visible_columns> // Desktop Columns Menu
<MultiSelectTrigger class="w-[140px] h-9"> <div class="hidden md:flex">
<div class="flex items-center gap-2 text-xs"> <MultiSelect values=visible_columns>
<Settings2 class="size-4" /> <MultiSelectTrigger class="w-[140px] h-9">
"Sütunlar" <div class="flex items-center gap-2 text-xs">
</div> <Settings2 class="size-4" />
</MultiSelectTrigger> "Sütunlar"
<MultiSelectContent> </div>
<MultiSelectGroup> </MultiSelectTrigger>
{ALL_COLUMNS.into_iter().map(|(id, label)| { <MultiSelectContent>
let id_val = id.to_string(); <MultiSelectGroup>
view! { {ALL_COLUMNS.into_iter().map(|(id, label)| {
<MultiSelectItem> let id_val = id.to_string();
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name"> view! {
{label} <MultiSelectItem>
</MultiSelectOption> <MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
</MultiSelectItem> {label}
}.into_any() </MultiSelectOption>
}).collect_view()} </MultiSelectItem>
</MultiSelectGroup> }.into_any()
</MultiSelectContent> }).collect_view()}
</MultiSelect> </MultiSelectGroup>
</MultiSelectContent>
</MultiSelect>
</div>
</div> </div>
</div> </div>
@@ -429,7 +506,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! {
@@ -442,7 +519,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>
@@ -483,7 +578,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();
@@ -539,8 +633,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({
@@ -587,6 +691,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();
@@ -601,48 +707,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 {
"transition-all" "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"> <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,41 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum BadgeVariant {
#[default]
Default,
Secondary,
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::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

@@ -209,7 +209,7 @@ pub fn ContextMenuTrigger(
class=trigger_class class=trigger_class
data-name="ContextMenuTrigger" data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id data-context-trigger=ctx.target_id
on:contextmenu=move |e: web_sys::MouseEvent| { on:contextmenu=move |_e: web_sys::MouseEvent| {
if let Some(cb) = on_open { if let Some(cb) = on_open {
cb.run(()); cb.run(());
} }

View File

@@ -1,6 +1,6 @@
// * Reuse @table.rs // * Reuse @table.rs
pub use crate::components::ui::table::{ pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell, Table as DataTable, TableBody as DataTableBody, TableCell as DataTableCell,
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper, TableRow as DataTableRow, TableWrapper as DataTableWrapper,
}; };

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

@@ -94,8 +94,6 @@ pub fn DropdownMenuAction(
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>, #[prop(optional, into)] href: Option<String>,
) -> impl IntoView { ) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
let class = tw_merge!( let class = tw_merge!(
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground", "inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
class class
@@ -175,17 +173,15 @@ pub enum DropdownMenuAlign {
#[derive(Clone)] #[derive(Clone)]
struct DropdownMenuContext { struct DropdownMenuContext {
target_id: String, target_id: String,
align: DropdownMenuAlign,
} }
#[component] #[component]
pub fn DropdownMenu( pub fn DropdownMenu(
children: Children, children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> impl IntoView { ) -> impl IntoView {
let dropdown_target_id = use_random_id_for("dropdown"); let dropdown_target_id = use_random_id_for("dropdown");
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align }; let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone() };
view! { view! {
<Provider value=ctx> <Provider value=ctx>
@@ -252,12 +248,13 @@ pub enum DropdownMenuPosition {
pub fn DropdownMenuContent( pub fn DropdownMenuContent(
children: Children, children: Children,
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition, #[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
) -> impl IntoView { ) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>(); let ctx = expect_context::<DropdownMenuContext>();
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100"; let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
let width_class = match ctx.align { let width_class = match align {
DropdownMenuAlign::Center => "min-w-full", DropdownMenuAlign::Center => "min-w-full",
_ => "w-[180px]", _ => "w-[180px]",
}; };
@@ -265,7 +262,7 @@ pub fn DropdownMenuContent(
let class = tw_merge!(width_class, base_classes, class); let class = tw_merge!(width_class, base_classes, class);
let target_id_for_script = ctx.target_id.clone(); let target_id_for_script = ctx.target_id.clone();
let align_for_script = match ctx.align { let align_for_script = match align {
DropdownMenuAlign::Start => "start", DropdownMenuAlign::Start => "start",
DropdownMenuAlign::StartOuter => "start-outer", DropdownMenuAlign::StartOuter => "start-outer",
DropdownMenuAlign::End => "end", DropdownMenuAlign::End => "end",
@@ -442,26 +439,6 @@ pub fn DropdownMenuContent(
trigger.addEventListener('click', (e) => {{ trigger.addEventListener('click', (e) => {{
e.stopPropagation(); e.stopPropagation();
// Check if any other dropdown is open
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
let otherDropdownOpen = false;
allDropdowns.forEach(dd => {{
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
otherDropdownOpen = true;
dd.setAttribute('data-state', 'closed');
dd.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}}
}});
// If another dropdown was open, just close it and don't open this one
if (otherDropdownOpen) {{
return;
}}
// Normal toggle behavior // Normal toggle behavior
if (isOpen) {{ if (isOpen) {{
closeDropdown(); closeDropdown();

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

@@ -9,7 +9,7 @@ use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for; use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs // * Reuse @select.rs
pub use crate::components::ui::select::{ pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel, SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
}; };
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]

View File

@@ -31,9 +31,6 @@ pub struct SheetContext {
/* ✨ FUNCTIONS ✨ */ /* ✨ FUNCTIONS ✨ */
/* ========================================================== */ /* ========================================================== */
pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;
#[component] #[component]
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let sheet_target_id = use_random_id_for("sheet"); let sheet_target_id = use_random_id_for("sheet");
@@ -203,7 +200,7 @@ pub fn SheetContent(
target_id_for_script, target_id_for_script,
)} )}
</script> </script>
}.into_any() }
} }
/* ========================================================== */ /* ========================================================== */

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",