Compare commits

...

38 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
spinline
4dcbd8187e feat: enhance bulk delete dialog with 'delete with data' option
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-12 22:08:14 +03:00
spinline
6c0c0a0919 fix: resolve syntax error in app.rs and cleanup duplicate blocks
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-12 22:02:14 +03:00
spinline
3158a11229 feat: add responsive mobile card view for torrents
Some checks failed
Build MIPS Binary / build (push) Failing after 1m19s
2026-02-12 21:59:36 +03:00
spinline
45f5d1b678 feat: implement contextual skeletons for login and dashboard
Some checks failed
Build MIPS Binary / build (push) Failing after 1m25s
2026-02-12 21:55:14 +03:00
spinline
c8e3caa4fc feat: implement and use standardized Skeleton component
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:49:58 +03:00
spinline
98555f16ca fix: refactor toast to use Flexbox layout to prevent overlapping
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:47:14 +03:00
spinline
5449651db6 feat: remove status bar from layout
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-12 21:38:25 +03:00
spinline
1156f0a111 fix: disable wasm preload to resolve browser warning
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:35:34 +03:00
spinline
9b8c075d41 feat: add click animation and hover effects to table headers
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:33:56 +03:00
spinline
c5679f043d fix: remove toast stacking logic to list notifications vertically
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 21:29:15 +03:00
spinline
0b3d4d1fd4 feat: improve toast animations and update to original Zinc color palette
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-12 21:18:13 +03:00
spinline
b2fb411bb1 fix: re-generate accordion module and stabilize ui/mod.rs to resolve CI/CD build errors
All checks were successful
Build MIPS Binary / build (push) Successful in 5m26s
2026-02-12 20:31:28 +03:00
spinline
1225c550b7 fix: resolve missing module errors for accordion and sonner by stabilizing ui/mod.rs
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
2026-02-12 20:26:15 +03:00
spinline
48193db81b feat: implement professional Sidenav layout and mobile Sheet menu
Some checks failed
Build MIPS Binary / build (push) Failing after 1m29s
2026-02-12 20:19:39 +03:00
spinline
48d8a8e0ee feat: implement stacked toast accumulation with hover expansion effect
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 19:59:22 +03:00
spinline
945f4718eb feat: match bulk action button style with columns button and add progress bar to toasts
All checks were successful
Build MIPS Binary / build (push) Successful in 5m33s
2026-02-12 19:42:32 +03:00
spinline
6a2952c6f3 fix: resolve 404 error for lock_scroll.js by including it in Trunk build and loading globally
All checks were successful
Build MIPS Binary / build (push) Successful in 5m32s
2026-02-12 01:51:38 +03:00
28 changed files with 1367 additions and 894 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)

37
Cargo.lock generated
View File

@@ -1259,6 +1259,7 @@ dependencies = [
"gloo-console", "gloo-console",
"gloo-net", "gloo-net",
"gloo-timers", "gloo-timers",
"icons",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-use", "leptos-use",
@@ -1271,7 +1272,7 @@ dependencies = [
"serde_json", "serde_json",
"shared", "shared",
"struct-patch", "struct-patch",
"strum", "strum 0.26.3",
"tailwind_fuse", "tailwind_fuse",
"thiserror 2.0.18", "thiserror 2.0.18",
"tw_merge", "tw_merge",
@@ -1864,6 +1865,17 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "icons"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75da3d473e24e0b541bf28cf31e67b262c379a4cbc2149f4865b7d99406711dc"
dependencies = [
"leptos",
"strum 0.27.2",
"tw_merge",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@@ -4137,7 +4149,16 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [ dependencies = [
"strum_macros", "strum_macros 0.26.4",
]
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros 0.27.2",
] ]
[[package]] [[package]]
@@ -4153,6 +4174,18 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View File

@@ -20,11 +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" /> <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="sw.js" /> <link data-trunk rel="copy-file" href="sw.js" />
<script> <script>
(function () { (function () {

View File

@@ -1,177 +1,92 @@
@import "tailwindcss"; @import "tailwindcss";
@config "./tailwind.config.js"; @import "tw-animate-css";
@source "../src/**/*.rs";
@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs";
@theme {
/* Shadcn Colors */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary)); :root {
--color-primary-foreground: hsl(var(--primary-foreground)); --background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
--color-secondary: hsl(var(--secondary)); .dark {
--color-secondary-foreground: hsl(var(--secondary-foreground)); --background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
--color-destructive: hsl(var(--destructive)); @theme inline {
--color-destructive-foreground: hsl(var(--destructive-foreground)); --color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-muted: hsl(var(--muted)); --color-card: hsl(var(--card));
--color-muted-foreground: hsl(var(--muted-foreground)); --color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-accent: hsl(var(--accent)); --color-popover-foreground: hsl(var(--popover-foreground));
--color-accent-foreground: hsl(var(--accent-foreground)); --color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-popover: hsl(var(--popover)); --color-secondary: hsl(var(--secondary));
--color-popover-foreground: hsl(var(--popover-foreground)); --color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-card: hsl(var(--card)); --color-muted-foreground: hsl(var(--muted-foreground));
--color-card-foreground: hsl(var(--card-foreground)); --color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--radius-lg: var(--radius); --color-destructive: hsl(var(--destructive));
--radius-md: calc(var(--radius) - 2px); --color-destructive-foreground: hsl(var(--destructive-foreground));
--radius-sm: calc(var(--radius) - 4px); --color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--animate-accordion-down: accordion-down 0.2s ease-out; --color-ring: hsl(var(--ring));
--animate-accordion-up: accordion-up 0.2s ease-out; --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@keyframes accordion-down { --radius-lg: var(--radius);
from { --radius-xl: calc(var(--radius) + 4px);
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
} }
@layer base { @layer base {
:root { * {
--background: 0 0% 100%; @apply border-border outline-ring/50;
--foreground: 222.2 84% 4.9%; }
body {
@apply bg-background text-foreground;
}
--card: 0 0% 100%; button:not(:disabled),
--card-foreground: 222.2 84% 4.9%; [role="button"]:not(:disabled) {
cursor: pointer;
}
--popover: 0 0% 100%; dialog {
--popover-foreground: 222.2 84% 4.9%; margin: auto;
}
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* Ensure Shadcn Utilities are always available */
.bg-popover {
background-color: hsl(var(--popover));
}
.text-popover-foreground {
color: hsl(var(--popover-foreground));
}
.border-border {
border-color: hsl(var(--border));
}
.shadow-md {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.z-50 {
z-index: 50;
}
.z-100 {
z-index: 100;
}
}
/* Fix for iOS click/blur events */
@media (hover: none) {
body {
cursor: pointer;
}
}
/* Remove default focus outline/tap highlight */
* {
-webkit-tap-highlight-color: transparent;
outline: none !important;
}
:focus {
outline: none !important;
} }

View File

@@ -1,4 +1,5 @@
use crate::components::layout::protected::Protected; use crate::components::layout::protected::Protected;
use crate::components::ui::skeleton::Skeleton;
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;
@@ -30,7 +31,9 @@ pub fn App() -> impl IntoView {
view! { view! {
<Toaster /> <Toaster />
<InnerApp /> <Router>
<InnerApp />
</Router>
} }
} }
@@ -97,119 +100,121 @@ fn InnerApp() -> impl IntoView {
view! { view! {
<div class="relative w-full h-screen" style="height: 100dvh;"> <div class="relative w-full h-screen" style="height: 100dvh;">
<Router> <Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }> <Route path=leptos_router::path!("/login") view=move || {
<Route path=leptos_router::path!("/login") view=move || { let authenticated = is_authenticated.0.get();
let authenticated = is_authenticated.0.get(); let setup_needed = needs_setup.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| { Effect::new(move |_| {
if setup_needed { if setup_needed {
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if authenticated { } else if !is_authenticated.0.get() {
log::info!("Already authenticated, redirecting to home"); log::info!("Not authenticated, redirecting to login");
let navigate = use_navigate(); navigate("/login", Default::default());
navigate("/", Default::default());
} }
}); }
});
view! { <Login /> } view! {
} /> <Show when=move || !is_loading.0.get() fallback=|| {
<Route path=leptos_router::path!("/setup") view=move || { // Standard 1: Always show Dashboard Skeleton
Effect::new(move |_| { view! {
if is_authenticated.0.get() { <div class="flex h-screen bg-background text-foreground overflow-hidden">
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex h-screen bg-background">
// Sidebar skeleton // 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">
<div class="h-8 w-3/4 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-8 w-3/4" />
<div class="space-y-2"> <div class="space-y-2">
<div class="h-6 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-full" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-full" />
<div class="h-6 w-4/5 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-4/5" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-full" />
<div class="h-6 w-3/5 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-3/5" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-6 w-full" />
</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">
<div class="h-8 w-48 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-8 w-48" />
<div class="h-8 w-64 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-8 w-64" />
<div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div> <div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
</div> </div>
<div class="flex-1 p-4 space-y-3"> <div class="flex-1 p-4 space-y-3">
<div class="h-10 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-full" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-full" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-full" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-full" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-full" />
<div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-10 w-3/4" />
</div> </div>
<div class="border-t border-border p-3"> <div class="border-t border-border p-3">
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" /> <Skeleton class="h-5 w-96" />
</div> </div>
</div> </div>
</div> </div>
}.into_any()> }.into_any()
<Show when=move || is_authenticated.0.get() fallback=|| ()> }>
<Protected> <Show when=move || is_authenticated.0.get() fallback=|| ()>
<div class="flex flex-col h-full overflow-hidden"> <Protected>
<div class="flex-1 overflow-hidden"> <div class="flex flex-col h-full overflow-hidden">
<TorrentTable /> <div class="flex-1 overflow-hidden">
</div> <TorrentTable />
</div> </div>
</Protected> </div>
</Show> </Protected>
</Show> </Show>
}.into_any() </Show>
}/> }.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || { <Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| { let authenticated = is_authenticated.0.get();
if !is_authenticated.0.get() { Effect::new(move |_| {
let navigate = use_navigate(); if !authenticated {
navigate("/login", Default::default()); let navigate = use_navigate();
} navigate("/login", Default::default());
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
} }
}/> });
</Routes>
</Router> view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || authenticated fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</div> </div>
} }
} }

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,4 +1,4 @@
pub mod sidebar; pub mod sidebar;
pub mod statusbar;
pub mod toolbar; pub mod toolbar;
pub mod footer;
pub mod protected; pub mod protected;

View File

@@ -1,52 +1,31 @@
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::statusbar::StatusBar; use crate::components::layout::footer::Footer;
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
#[component] #[component]
pub fn Protected(children: Children) -> impl IntoView { pub fn Protected(children: Children) -> impl IntoView {
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
let is_mobile_menu_open = RwSignal::new(false);
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
provide_context(is_mobile_menu_open);
view! { view! {
<div class="flex h-screen w-full overflow-hidden bg-background"> <SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
// Masaüstü Sidenav
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) --- <Sidenav>
<aside class=move || {
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
if is_mobile_menu_open.get() {
format!("{} translate-x-0", base)
} else {
format!("{} -translate-x-full", base)
}
}>
<Sidebar /> <Sidebar />
</aside> </Sidenav>
// Mobil arka plan karartma (Overlay) // İçerik Alanı
<Show when=move || is_mobile_menu_open.get()> <SidenavInset class="flex flex-col h-screen overflow-hidden">
<div // Toolbar (Üst Bar)
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
on:click=move |_| is_mobile_menu_open.set(false)
></div>
</Show>
// --- MAIN CONTENT AREA ---
<div class="flex flex-1 flex-col overflow-hidden">
// --- TOOLBAR (TOP) ---
<Toolbar /> <Toolbar />
// --- MAIN CONTENT --- // 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>
// --- STATUS BAR (BOTTOM) --- </SidenavWrapper>
<StatusBar />
</div>
</div>
} }
} }

View File

@@ -1,11 +1,12 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::ui::sidenav::*;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize}; use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::theme_toggle::ThemeToggle;
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> 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 is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let total_count = move || store.torrents.with(|map| map.len()); let total_count = move || store.torrents.with(|map| map.len());
let downloading_count = move || { let downloading_count = move || {
@@ -52,7 +53,6 @@ pub fn Sidebar() -> impl IntoView {
let set_filter = move |f: crate::store::FilterStatus| { let set_filter = move |f: crate::store::FilterStatus| {
store.filter.set(f); store.filter.set(f);
is_mobile_menu_open.set(false);
}; };
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f; let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
@@ -66,83 +66,89 @@ pub fn Sidebar() -> impl IntoView {
}; };
view! { view! {
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);"> <SidenavHeader>
<div class="p-4 flex-1 overflow-y-auto"> <div class="flex items-center gap-2 px-2 py-4">
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground"> <div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
"VibeTorrent" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
</svg>
</div> </div>
<div class="space-y-1"> <div class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4> <span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
on_click=move |_| set_filter(crate::store::FilterStatus::All)
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
label="All"
count=Signal::derive(total_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
label="Downloading"
count=Signal::derive(downloading_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
label="Seeding"
count=Signal::derive(seeding_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
label="Completed"
count=Signal::derive(completed_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
label="Paused"
count=Signal::derive(paused_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
label="Inactive"
count=Signal::derive(inactive_count)
/>
</div> </div>
</div> </div>
</SidenavHeader>
// Separator <SidenavContent>
<div class="border-t border-border" /> <SidenavGroup>
<SidenavGroupLabel>"Filtreler"</SidenavGroupLabel>
<SidenavGroupContent>
<SidenavMenu>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
on_click=move |_| set_filter(crate::store::FilterStatus::All)
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
label="Tümü"
count=Signal::derive(total_count)
/>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
label="İndirilenler"
count=Signal::derive(downloading_count)
/>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
label="Gönderilenler"
count=Signal::derive(seeding_count)
/>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
label="Tamamlananlar"
count=Signal::derive(completed_count)
/>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
label="Durdurulanlar"
count=Signal::derive(paused_count)
/>
<SidebarItem
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
label="Pasif"
count=Signal::derive(inactive_count)
/>
</SidenavMenu>
</SidenavGroupContent>
</SidenavGroup>
</SidenavContent>
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));"> <SidenavFooter>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
// Avatar <div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0"> {first_letter}
{first_letter} </div>
</div> <div class="flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden"> <div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="font-medium text-sm truncate text-foreground">{username}</div> <div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div> </div>
</div>
<div class="flex items-center gap-1">
<ThemeToggle />
// Theme toggle button
<div class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors">
<crate::components::ui::theme_toggle::ThemeToggle />
</div>
// Logout button
<Button <Button
variant=ButtonVariant::Ghost variant=ButtonVariant::Ghost
size=ButtonSize::Icon size=ButtonSize::Icon
class="text-destructive hover:bg-destructive/10" class="size-7 text-destructive hover:bg-destructive/10"
attr:disabled=move || false
on:click=move |_| { on:click=move |_| {
spawn_local(async move { spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() { if shared::server_fns::auth::logout().await.is_ok() {
@@ -152,37 +158,40 @@ pub fn Sidebar() -> impl IntoView {
}); });
} }
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
</Button> </Button>
</div> </div>
</div> </div>
</div> </SidenavFooter>
} }
} }
#[component] #[component]
fn SidebarButton( fn SidebarItem(
active: Signal<bool>, active: Signal<bool>,
on_click: impl Fn(web_sys::MouseEvent) + 'static, on_click: impl Fn(web_sys::MouseEvent) + 'static + Send,
#[prop(into)] icon: String, #[prop(into)] icon: String,
#[prop(into)] label: &'static str, #[prop(into)] label: &'static str,
count: Signal<usize>, count: Signal<usize>,
) -> impl IntoView { ) -> impl IntoView {
let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost }; let variant = move || if active.get() { SidenavMenuButtonVariant::Outline } else { SidenavMenuButtonVariant::Default };
let class = move || if active.get() { "bg-accent/50 border-accent text-foreground".to_string() } else { "text-muted-foreground hover:text-foreground".to_string() };
view! { view! {
<Button <SidenavMenuItem>
variant=Signal::derive(variant) <SidenavMenuButton
class="justify-start gap-2 w-full h-8 px-3" variant=Signal::derive(variant)
on:click=on_click class=Signal::derive(class)
> on:click=on_click
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> >
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() /> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
</svg> <path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
{label} </svg>
<span class="ml-auto text-xs font-mono opacity-70">{count}</span> <span class="flex-1 truncate">{label}</span>
</Button> <span class="text-[10px] font-mono opacity-50">{count}</span>
</SidenavMenuButton>
</SidenavMenuItem>
} }
} }

View File

@@ -1,201 +0,0 @@
use leptos::prelude::*;
use leptos::html;
use shared::GlobalLimitRequest;
use crate::api;
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 {
return format!("{} B", bytes);
}
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!(
"{:.1} {}",
(bytes as f64) / 1024_f64.powi(i as i32),
UNITS[i]
)
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 {
return "0 B/s".to_string();
}
format!("{}/s", format_bytes(bytes_per_sec))
}
#[component]
pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats;
// Preset limits in bytes/s
let limits: Vec<(i64, &str)> = vec!(
(0, "Unlimited"),
(100 * 1024, "100 KB/s"),
(500 * 1024, "500 KB/s"),
(1024 * 1024, "1 MB/s"),
(2 * 1024 * 1024, "2 MB/s"),
(5 * 1024 * 1024, "5 MB/s"),
(10 * 1024 * 1024, "10 MB/s"),
(20 * 1024 * 1024, "20 MB/s"),
);
let set_limit = move |limit_type: &str, val: i64| {
let limit_type = limit_type.to_string();
log::info!("Setting {} limit to {}", limit_type, val);
let req = if limit_type == "down" {
GlobalLimitRequest {
max_download_rate: Some(val),
max_upload_rate: None,
}
} else {
GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
leptos::task::spawn_local(async move {
if let Err(e) = api::settings::set_global_limits(&req).await {
log::error!("Failed to set limit: {:?}", e);
} else {
log::info!("Limit set successfully");
}
});
};
let down_details_ref = NodeRef::<html::Details>::new();
let up_details_ref = NodeRef::<html::Details>::new();
let close_details = move |node_ref: NodeRef<html::Details>| {
if let Some(el) = node_ref.get_untracked() {
el.set_open(false);
}
};
view! {
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-muted border-t border-border flex items-center px-4 text-xs gap-4 text-muted-foreground z-[99] cursor-pointer">
// --- DOWNLOAD SPEED DROPDOWN ---
<details class="group relative" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
<span class="font-mono">{move || format_speed(stats.get().down_rate)}</span>
<Show when=move || { stats.get().down_limit.unwrap_or(0) > 0 } fallback=|| ()>
<span class="text-[10px] opacity-60">
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
</span>
</Show>
</summary>
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
// --- UPLOAD SPEED DROPDOWN ---
<details class="group relative" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
<span class="font-mono">{move || format_speed(stats.get().up_rate)}</span>
<Show when=move || { stats.get().up_limit.unwrap_or(0) > 0 } fallback=|| ()>
<span class="text-[10px] opacity-60">
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
</span>
</Show>
</summary>
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_limit("up", val);
close_details(up_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
<div class="ml-auto flex items-center gap-4">
<button
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7"
title="Settings & Notification Permissions"
on:click=move |_| {
// Request push notification permission
leptos::task::spawn_local(async {
// ... existing logic ...
crate::store::subscribe_to_push_notifications().await;
// ... existing logic ...
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.212 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 012.6-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
}
}

View File

@@ -1,23 +1,37 @@
use leptos::prelude::*; use leptos::prelude::*;
use icons::PanelLeft;
use crate::components::torrent::add_torrent::AddTorrentDialog; use crate::components::torrent::add_torrent::AddTorrentDialog;
use crate::components::ui::button::{Button}; use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
use crate::components::layout::sidebar::Sidebar;
#[component] #[component]
pub fn Toolbar() -> impl IntoView { pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false); let show_add_modal = signal(false);
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
view! { view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);"> <div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu + Add Torrent // Sol kısım: Menü butonu (Mobil) + Add Torrent
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
// Mobile Menu Trigger
<button // --- MOBILE SHEET (SIDEBAR) ---
class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden" <div class="lg:hidden">
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v) <Sheet>
> <SheetTrigger variant=ButtonVariant::Ghost size=ButtonSize::Icon class="size-9">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> <PanelLeft class="size-5" />
</button> <span class="hidden">"Menüyü Aç"</span>
</SheetTrigger>
<SheetContent
direction=SheetDirection::Left
class="p-0 w-[18rem] bg-card border-r border-border"
hide_close_button=true
>
<div class="flex flex-col h-full overflow-hidden">
<Sidebar />
</div>
</SheetContent>
</Sheet>
</div>
<Button <Button
on:click=move |_| show_add_modal.1.set(true) on:click=move |_| show_add_modal.1.set(true)
@@ -31,7 +45,7 @@ pub fn Toolbar() -> impl IntoView {
</Button> </Button>
</div> </div>
// Sağ kısım boşaltıldı (arama kutusu kaldırıldı) // Sağ kısım boş
<div class="flex flex-1 items-center justify-end gap-2"> <div class="flex flex-1 items-center justify-end gap-2">
</div> </div>

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,77 +231,162 @@ 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="gap-2 bg-secondary text-secondary-foreground border-none hover:bg-secondary/80"> <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());
}
}>
<Trash2 class="mr-2 size-4" /> "Toplu Sil..."
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
<AlertDialog>
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
<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 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
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> </div>
</AlertDialogTrigger> </AlertDialogFooter>
<AlertDialogContent> </AlertDialogBody>
<AlertDialogHeader> </AlertDialogContent>
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle> </AlertDialog>
<AlertDialogDescription> </div>
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())} </Show>
</AlertDialogDescription>
</AlertDialogHeader> // Mobile Sort Menu
<AlertDialogFooter> <div class="block md:hidden">
<AlertDialogClose>"İptal"</AlertDialogClose> <DropdownMenu>
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")> <DropdownMenuTrigger class="w-[100px] h-9 gap-2 text-xs">
"Sil" <ListFilter class="size-4" />
</Button> "Sırala"
</AlertDialogFooter> </DropdownMenuTrigger>
</AlertDialogContent> <DropdownMenuContent class="w-56">
</AlertDialog> <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>
// --- MAIN TABLE --- // --- MAIN CONTENT ---
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<DataTableWrapper class="h-full bg-card/50"> // Desktop Table View
<DataTableWrapper class="hidden md:block h-full bg-card/50">
<div class="h-full overflow-auto"> <div class="h-full overflow-auto">
<DataTable> <DataTable>
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10"> <DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
@@ -305,49 +402,49 @@ pub fn TorrentTable() -> impl IntoView {
</DataTableHead> </DataTableHead>
{move || visible_columns.get().contains("Name").then(|| view! { {move || visible_columns.get().contains("Name").then(|| view! {
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)> <DataTableHead class="cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Name)>
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div> <div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("Size").then(|| view! { {move || visible_columns.get().contains("Size").then(|| view! {
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)> <DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Size)>
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div> <div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("Progress").then(|| view! { {move || visible_columns.get().contains("Progress").then(|| view! {
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)> <DataTableHead class="w-48 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Progress)>
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div> <div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("Status").then(|| view! { {move || visible_columns.get().contains("Status").then(|| view! {
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)> <DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Status)>
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div> <div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("DownSpeed").then(|| view! { {move || visible_columns.get().contains("DownSpeed").then(|| view! {
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)> <DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div> <div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("UpSpeed").then(|| view! { {move || visible_columns.get().contains("UpSpeed").then(|| view! {
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)> <DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div> <div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("ETA").then(|| view! { {move || visible_columns.get().contains("ETA").then(|| view! {
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)> <DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div> <div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
{move || visible_columns.get().contains("AddedDate").then(|| view! { {move || visible_columns.get().contains("AddedDate").then(|| view! {
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)> <DataTableHead class="w-32 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div> <div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
</DataTableHead> </DataTableHead>
}).into_any()} }).into_any()}
@@ -407,6 +504,44 @@ pub fn TorrentTable() -> impl IntoView {
</DataTable> </DataTable>
</div> </div>
</DataTableWrapper> </DataTableWrapper>
// Mobile Card View
<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! {
<div class="flex flex-col items-center justify-center h-64 opacity-50 text-muted-foreground">
<Inbox class="size-12 mb-2" />
<p>"Torrent Bulunamadı"</p>
</div>
}.into_any()
>
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
let is_selected = Signal::derive(move || {
selected_hashes.with(|selected| selected.contains(&h))
});
let h_for_change = hash.clone();
view! {
<TorrentCard
hash=hash.clone()
on_action=on_action.clone()
is_selected=is_selected
on_select=Callback::new(move |checked| {
selected_hashes.update(|selected| {
if checked { selected.insert(h_for_change.clone()); }
else { selected.remove(&h_for_change); }
});
})
/>
}
}
} />
</Show>
</div>
</div> </div>
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md"> <div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
@@ -443,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();
@@ -499,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({
@@ -547,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();
@@ -561,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,39 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("w-full", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("border-b", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
class
);
view! {
<button type="button" class=class>
{children()}
</button>
}
}
#[component]
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("overflow-hidden text-sm transition-all", class);
view! { <div class=class>{children()}</div> }
}

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(());
} }
@@ -237,8 +237,6 @@ pub fn ContextMenuContent(
let target_id_for_script = ctx.target_id.clone(); let target_id_for_script = ctx.target_id.clone();
view! { view! {
<script src="/lock_scroll.js"></script>
<div <div
data-name="ContextMenuContent" data-name="ContextMenuContent"
class=class class=class

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

@@ -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",
@@ -280,8 +277,6 @@ pub fn DropdownMenuContent(
}; };
view! { view! {
<script src="/hooks/lock_scroll.js"></script>
<div <div
data-name="DropdownMenuContent" data-name="DropdownMenuContent"
class=class class=class
@@ -444,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,4 +1,6 @@
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;
@@ -11,6 +13,9 @@ pub mod input;
pub mod multi_select; pub mod multi_select;
pub mod select; pub mod select;
pub mod separator; pub mod separator;
pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod svg_icon; pub mod svg_icon;
pub mod table; pub mod table;
pub mod theme_toggle; pub mod theme_toggle;

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)]
@@ -180,8 +180,6 @@ pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: Str
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical(); let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! { view! {
<script src="/lock_scroll.js"></script>
<div <div
data-name="MultiSelectContent" data-name="MultiSelectContent"
class=class class=class

View File

@@ -172,8 +172,6 @@ pub fn SelectContent(
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical(); let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! { view! {
<script src="/lock_scroll.js"></script>
<div <div
data-name="SelectContent" data-name="SelectContent"
class=merged_class class=merged_class

View File

@@ -0,0 +1,236 @@
use icons::X;
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use tw_merge::*;
use super::button::ButtonSize;
use crate::components::hooks::use_random::use_random_id_for;
use crate::components::ui::button::{Button, ButtonVariant};
mod components {
use super::*;
clx! {SheetTitle, h2, "font-bold text-2xl"}
clx! {SheetDescription, p, "text-muted-foreground"}
clx! {SheetBody, div, "flex flex-col gap-4"}
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
}
pub use components::*;
/* ========================================================== */
/* ✨ CONTEXT ✨ */
/* ========================================================== */
#[derive(Clone)]
pub struct SheetContext {
pub target_id: String,
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let sheet_target_id = use_random_id_for("sheet");
let ctx = SheetContext { target_id: sheet_target_id };
let merged_class = tw_merge!("", class);
view! {
<Provider value=ctx>
<div data-name="Sheet" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn SheetTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
let trigger_id = format!("trigger_{}", ctx.target_id);
view! {
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
{children()}
</Button>
}
}
#[component]
pub fn SheetClose(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
view! {
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
{children()}
</Button>
}
}
#[component]
pub fn SheetContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = SheetDirection::Right)] direction: SheetDirection,
#[prop(into, optional)] hide_close_button: Option<bool>,
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
let backdrop_id = format!("{}_backdrop", ctx.target_id);
let target_id_for_script = ctx.target_id.clone();
let backdrop_id_for_script = backdrop_id.clone();
let merged_class = tw_merge!(
"fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain",
direction.initial_position(),
direction.closed_class(),
class
);
view! {
<div
data-name="SheetBackdrop"
id=backdrop_id
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
data-state="closed"
/>
<div
data-name="SheetContent"
class=merged_class
id=ctx.target_id
data-direction=direction.to_string()
data-state="closed"
style="pointer-events: none;"
>
<button
type="button"
class=format!(
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
)
data-sheet-close=ctx.target_id.clone()
aria-label="Close sheet"
>
<span class="hidden">"Close Sheet"</span>
<X />
</button>
{children()}
</div>
<script>
{format!(
r#"
(function() {{
const setupSheet = () => {{
const sheet = document.querySelector('#{}');
const backdrop = document.querySelector('#{}');
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
if (!sheet || !backdrop || !trigger) {{
setTimeout(setupSheet, 50);
return;
}}
if (sheet.hasAttribute('data-initialized')) {{
return;
}}
sheet.setAttribute('data-initialized', 'true');
const openSheet = () => {{
if (window.ScrollLock) window.ScrollLock.lock();
sheet.setAttribute('data-state', 'open');
backdrop.setAttribute('data-state', 'open');
sheet.style.pointerEvents = 'auto';
backdrop.style.pointerEvents = 'auto';
const direction = sheet.getAttribute('data-direction');
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
sheet.classList.add('translate-x-0', 'translate-y-0');
}};
const closeSheet = () => {{
sheet.setAttribute('data-state', 'closed');
backdrop.setAttribute('data-state', 'closed');
sheet.style.pointerEvents = 'none';
backdrop.style.pointerEvents = 'none';
const direction = sheet.getAttribute('data-direction');
sheet.classList.remove('translate-x-0', 'translate-y-0');
if (direction === 'Right') sheet.classList.add('translate-x-full');
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
else if (direction === 'Top') sheet.classList.add('-translate-y-full');
else if (direction === 'Bottom') sheet.classList.add('translate-y-full');
if (window.ScrollLock) window.ScrollLock.unlock(300);
}};
trigger.addEventListener('click', openSheet);
const closeButtons = sheet.querySelectorAll('[data-sheet-close]');
closeButtons.forEach(btn => btn.addEventListener('click', closeSheet));
backdrop.addEventListener('click', closeSheet);
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && sheet.getAttribute('data-state') === 'open') {{
e.preventDefault();
closeSheet();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupSheet);
}} else {{
setupSheet();
}}
}})();
"#,
target_id_for_script,
backdrop_id_for_script,
target_id_for_script,
)}
</script>
}
}
/* ========================================================== */
/* ✨ ENUM ✨ */
/* ========================================================== */
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
pub enum SheetDirection {
Right,
Left,
Top,
Bottom,
}
impl SheetDirection {
fn closed_class(self) -> &'static str {
match self {
SheetDirection::Right => "translate-x-full",
SheetDirection::Left => "-translate-x-full",
SheetDirection::Top => "-translate-y-full",
SheetDirection::Bottom => "translate-y-full",
}
}
fn initial_position(self) -> &'static str {
match self {
SheetDirection::Right => "top-0 right-0 h-full w-[400px]",
SheetDirection::Left => "top-0 left-0 h-full w-[400px]",
SheetDirection::Top => "top-0 left-0 w-full h-[400px]",
SheetDirection::Bottom => "bottom-0 left-0 w-full h-[400px]",
}
}
}

View File

@@ -0,0 +1,232 @@
use leptos::prelude::*;
use leptos_router::hooks::use_location;
use leptos_ui::{clx, variants, void};
mod components {
use super::*;
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"}
// clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=Inset]:m-2 md:peer-data-[variant=Inset]:ml-0 md:peer-data-[variant=Inset]:rounded-xl md:peer-data-[variant=Inset]:shadow-sm md:peer-data-[variant=Inset]:peer-data-[state=Collapsed]:ml-2"}
clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col data-[variant=Inset]:rounded-lg data-[variant=Inset]:border data-[variant=Inset]:border-sidenav-border data-[variant=Inset]:shadow-sm data-[variant=Inset]:m-2"}
// * data-[], not group-data-[]
clx! {SidenavInner, div, "flex flex-col w-full h-full bg-sidenav data-[variant=Floating]:rounded-lg data-[variant=Floating]:border data-[variant=Floating]:border-sidenav-border data-[variant=Floating]:shadow-sm"}
clx! {SidenavHeader, div, "flex flex-col gap-2 p-2"}
clx! {SidenavMenu, ul, "flex flex-col gap-1 w-full min-w-0"}
clx! {SidenavMenuSub, ul, "border-sidenav-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=Icon]:hidden"}
clx! {SidenavMenuItem, li, "relative group/menu-item"}
clx! {SidenavContent, div, "scrollbar__on_hover", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=Icon]:overflow-hidden"}
clx! {SidenavGroup, div, "flex relative flex-col p-2 w-full min-w-0"}
clx! {SidenavGroupContent, div, "w-full text-sm"}
clx! {SidenavGroupLabel, div, "text-sidenav-foreground/70 ring-sidenav-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:-mt-8 group-data-[collapsible=Icon]:opacity-0"}
clx! {SidenavFooter, footer, "flex flex-col gap-2 p-2"}
// Button "More"
clx! {DropdownMenuTriggerEllipsis, button, "text-sidenav-foreground ring-sidenav-ring hover:bg-sidenav-accent hover:text-sidenav-accent-foreground peer-hover/menu-button:text-sidenav-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=Icon]:hidden peer-data-[active=true]/menu-button:text-sidenav-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0"}
void! {SidenavInput, input,
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"focus-visible:ring-2", // TODO. Port tw_merge to Tailwind V4.
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
// Specific to Sidenav
"w-full h-8 shadow-none bg-background"
}
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn SidenavLink(
children: Children,
#[prop(into)] href: String,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidenav-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-semibold aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidenav-accent hover:text-sidenav-accent-foreground h-8 text-sm",
class
);
let location = use_location();
// Check if the link is active based on current path
let href_clone = href.clone();
let is_active = move || {
let path = location.pathname.get();
path == href_clone || path.starts_with(&format!("{}/", href_clone))
};
let aria_current = move || if is_active() { "page" } else { "false" };
view! {
<a data-name="SidenavLink" class=merged_class href=href aria-current=aria_current>
{children()}
</a>
}
}
variants! {
SidenavMenuButton {
base: "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidenav-ring transition-[width,height,padding] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-medium aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
variants: {
variant: {
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base
Outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidenav-border))] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidenav-accent))]",
},
size: {
Default: "h-8 text-sm",
Sm: "h-7 text-xs",
Lg: "h-12",
}
},
component: {
element: button,
support_href: true,
support_aria_current: true
}
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, strum::IntoStaticStr)]
pub enum SidenavVariant {
#[default]
Sidenav,
Floating,
Inset,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavSide {
#[default]
Left,
Right,
}
#[derive(Default, Clone, Copy, PartialEq, Eq, strum::Display)]
pub enum SidenavCollapsible {
#[default]
Offcanvas,
None,
Icon,
}
#[component]
pub fn Sidenav(
#[prop(into, optional)] class: String,
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
#[prop(default = SidenavState::default())] data_state: SidenavState,
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
children: Children,
) -> impl IntoView {
view! {
{if data_collapsible == SidenavCollapsible::None {
view! {
<aside
data-name="Sidenav"
class=tw_merge!(
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone()
)
>
{children()}
</aside>
}
.into_any()
} else {
view! {
<aside
data-name="Sidenav"
data-sidenav=data_state.to_string()
data-side=data_side.to_string()
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
>
// * SidenavGap: This is what handles the sidenav gap on desktop
<div
data-name="SidenavGap"
class=tw_merge!(
"relative w-(--sidenav-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=Offcanvas]:w-0",
"group-data-[side=Right]:rotate-180",
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
SidenavVariant::Floating | SidenavVariant::Inset =>
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
}
)
/>
<div
data-name="SidenavContainer"
class=tw_merge!(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidenav-width) transition-[left,right,width] duration-200 ease-linear md:flex",
class,
match data_side {
SidenavSide::Left => "left-0 group-data-[collapsible=Offcanvas]:left-[calc(var(--sidenav-width)*-1)]",
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
},
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Floating | SidenavVariant::Inset =>
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
},
)
>
// * Act as a Sidenav for the onclick trigger to work with nested Sidenavs.
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
{children()}
<SidenavToggleRail />
</SidenavInner>
</div>
</aside>
}
.into_any()
}}
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavState {
#[default]
Expanded,
Collapsed,
}
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
#[component]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
view! {
// TODO. Use Button.
<button
onclick=ONCLICK_TRIGGER
data-name="SidenavTrigger"
class="inline-flex gap-2 justify-center items-center -ml-1 text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none disabled:opacity-50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-7 dark:aria-invalid:ring-destructive/40 dark:hover:bg-accent/50 hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
{children()}
</button>
}
}
#[component]
fn SidenavToggleRail() -> impl IntoView {
view! {
<button
data-name="SidenavToggleRail"
aria-label="Toggle Sidenav"
tabindex="-1"
onclick=ONCLICK_TRIGGER
class="hidden absolute inset-y-0 z-20 w-4 transition-all ease-linear -translate-x-1/2 sm:flex group-data-[side=Left]:-right-4 group-data-[side=Right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] in-data-[side=Left]:cursor-w-resize in-data-[side=Right]:cursor-e-resize [[data-side=Left][data-state=Collapsed]_&]:cursor-e-resize [[data-side=Right][data-state=Collapsed]_&]:cursor-w-resize group-data-[collapsible=Offcanvas]:translate-x-0 group-data-[collapsible=Offcanvas]:after:left-full [[data-side=Left][data-collapsible=Offcanvas]_&]:-right-2 [[data-side=Right][data-collapsible=Offcanvas]_&]:-left-0eft-2 hover:after:bg-sidenav-border hover:group-data-[collapsible=Offcanvas]:bg-sidenav"
/>
}
}

View File

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

View File

@@ -33,7 +33,11 @@ pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> im
#[component] #[component]
pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap", class); let class = tw_merge!(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap",
"transition-all duration-100 active:scale-[0.98] cursor-pointer select-none hover:bg-muted/30 hover:text-foreground",
class
);
view! { <th class=class>{children()}</th> } view! { <th class=class>{children()}</th> }
} }

View File

@@ -32,6 +32,7 @@ pub struct ToastData {
pub description: Option<String>, pub description: Option<String>,
pub variant: ToastType, pub variant: ToastType,
pub duration: u64, // ms pub duration: u64, // ms
pub is_exiting: RwSignal<bool>,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -45,8 +46,10 @@ pub fn SonnerTrigger(
index: usize, index: usize,
total: usize, total: usize,
position: SonnerPosition, position: SonnerPosition,
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",
@@ -56,39 +59,50 @@ pub fn SonnerTrigger(
ToastType::Loading => "bg-background text-foreground border-border", ToastType::Loading => "bg-background text-foreground border-border",
}; };
// Sonner Stacking Logic let bar_color = match toast.variant {
let inverse_index = index; ToastType::Success => "bg-green-500",
let offset = inverse_index as f64 * 12.0; ToastType::Error => "bg-destructive",
let scale = 1.0 - (inverse_index as f64 * 0.05); ToastType::Warning => "bg-yellow-500",
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.15) }; ToastType::Info => "bg-blue-500",
_ => "bg-primary",
};
let is_bottom = position.to_string().contains("Bottom"); // Simplified Style (No manual translateY needed with Flexbox)
let y_direction = if is_bottom { -1.0 } else { 1.0 }; let style = move || {
let translate_y = offset * y_direction; format!(
"z-index: {}; opacity: 1; transition: all 0.3s ease;",
total - index
)
};
let style = format!( let animation_class = move || {
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};", let pos = position.to_string();
total - index, let is_left = pos.contains("Left");
translate_y, let is_exiting = toast.is_exiting.get();
scale,
opacity match (is_left, is_exiting) {
); (true, false) => "animate-sonner-in-left",
(true, true) => "animate-sonner-out-left",
(false, false) => "animate-sonner-in-right",
(false, true) => "animate-sonner-out-right",
}
};
let icon = match toast.variant { let icon = match toast.variant {
ToastType::Success => Some(view! { <span class="icon font-bold">""</span> }.into_any()), ToastType::Success => Some(view! { <span class="icon font-bold text-lg">""</span> }.into_any()),
ToastType::Error => Some(view! { <span class="icon font-bold">""</span> }.into_any()), ToastType::Error => Some(view! { <span class="icon font-bold text-lg">""</span> }.into_any()),
ToastType::Warning => Some(view! { <span class="icon font-bold">""</span> }.into_any()), ToastType::Warning => Some(view! { <span class="icon font-bold text-lg">""</span> }.into_any()),
ToastType::Info => Some(view! { <span class="icon font-bold">""</span> }.into_any()), ToastType::Info => Some(view! { <span class="icon font-bold text-lg">""</span> }.into_any()),
_ => None, _ => None,
}; };
view! { view! {
<div <div
class=tw_merge!( class=move || tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto", "relative transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card", "flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-xl bg-card",
if is_bottom { "bottom-0" } else { "top-0" }, variant_classes,
variant_classes animation_class()
) )
style=style style=style
on:click=move |_| { on:click=move |_| {
@@ -98,10 +112,19 @@ pub fn SonnerTrigger(
} }
> >
{icon} {icon}
<div class="flex flex-col gap-0.5 overflow-hidden"> <div class="flex flex-col gap-0.5 overflow-hidden flex-1">
<div class="text-sm font-semibold truncate leading-tight">{toast.title}</div> <div class="text-sm font-bold truncate leading-tight">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })} {move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-80 truncate">{d.clone()}</div> })}
</div> </div>
// Progress Bar
<div
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-30", bar_color)
style=format!(
"animation: sonner-progress {}ms linear forwards; transform-origin: left;",
toast.duration
)
/>
</div> </div>
}.into_any() }.into_any()
} }
@@ -120,61 +143,64 @@ pub fn provide_toaster() {
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
let store = use_context::<ToasterStore>().expect("Toaster context not found"); let store = use_context::<ToasterStore>().expect("Toaster context not found");
let toasts = store.toasts; let toasts = store.toasts;
let is_hovered = RwSignal::new(false);
let (container_class, mobile_class) = match position { let is_bottom = position.to_string().contains("Bottom");
SonnerPosition::TopLeft => ("left-6 top-6 items-start", "left-4 top-4"),
SonnerPosition::TopRight => ("right-6 top-6 items-end", "right-4 top-4"), let container_class = match position {
SonnerPosition::TopCenter => ("left-1/2 -translate-x-1/2 top-6 items-center", "left-1/2 -translate-x-1/2 top-4"), SonnerPosition::TopLeft => "left-6 top-6 items-start",
SonnerPosition::BottomCenter => ("left-1/2 -translate-x-1/2 bottom-6 items-center", "left-1/2 -translate-x-1/2 bottom-4"), SonnerPosition::TopRight => "right-6 top-6 items-end",
SonnerPosition::BottomLeft => ("left-6 bottom-6 items-start", "left-4 bottom-4"), SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
SonnerPosition::BottomRight => ("right-6 bottom-6 items-end", "right-4 bottom-4"), SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
}; };
view! { view! {
<style>
"@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
@keyframes sonner-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes sonner-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
@keyframes sonner-in-left { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes sonner-out-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
.animate-sonner-in-right { animation: sonner-in-right 0.3s ease-out forwards; }
.animate-sonner-out-right { animation: sonner-out-right 0.3s ease-in forwards; }
.animate-sonner-in-left { animation: sonner-in-left 0.3s ease-out forwards; }
.animate-sonner-out-left { animation: sonner-out-left 0.3s ease-in forwards; }"
</style>
<div <div
class=tw_merge!( class=tw_merge!(
"fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]", "fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
if is_bottom { "flex-col-reverse" } else { "flex-col" },
container_class, container_class,
// Safe areas for mobile
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0" "pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
) )
on:mouseenter=move |_| is_hovered.set(true)
on:mouseleave=move |_| is_hovered.set(false)
> >
<For <For
each=move || { each=move || {
let list = toasts.get(); let list = toasts.get();
list.into_iter().rev().enumerate().collect::<Vec<_>>() list.into_iter().enumerate().collect::<Vec<_>>()
} }
key=|(_, toast)| toast.id key=|(_, toast)| toast.id
children=move |(index, toast)| { children=move |(index, toast)| {
let id = toast.id; let id = toast.id;
let total = toasts.with(|t| t.len()); let total = toasts.with(|t| t.len());
let is_exiting = toast.is_exiting;
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 64.0;
let is_bottom = position.to_string().contains("Bottom");
let y_dir = if is_bottom { -1.0 } else { 1.0 };
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
} else {
"".to_string()
}
};
view! { view! {
<div class="contents" style=expanded_style> <SonnerTrigger
<SonnerTrigger toast=toast
toast=toast index=index
index=index total=total
total=total position=position
position=position is_expanded=Signal::derive(move || true)
on_dismiss=Callback::new(move |_| { on_dismiss=Callback::new(move |_| {
is_exiting.set(true);
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(300).await;
toasts.update(|vec| vec.retain(|t| t.id != id)); toasts.update(|vec| vec.retain(|t| t.id != id));
}) });
/> })
</div> />
} }
} }
/> />
@@ -193,6 +219,7 @@ pub fn toast(title: impl Into<String>, variant: ToastType) {
description: None, description: None,
variant, variant,
duration: 4000, duration: 4000,
is_exiting: RwSignal::new(false),
}; };
toasts.update(|t| { toasts.update(|t| {
@@ -203,8 +230,11 @@ pub fn toast(title: impl Into<String>, variant: ToastType) {
}); });
let duration = new_toast.duration; let duration = new_toast.duration;
let is_exiting = new_toast.is_exiting;
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(duration as u32).await; gloo_timers::future::TimeoutFuture::new(duration as u32).await;
is_exiting.set(true);
gloo_timers::future::TimeoutFuture::new(300).await;
toasts.update(|vec| vec.retain(|t| t.id != id)); toasts.update(|vec| vec.retain(|t| t.id != id));
}); });
} }