feat: finalize shadcn integration with portal-based context menu and clean build
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s

This commit is contained in:
spinline
2026-02-10 23:16:13 +03:00
parent fddc81365b
commit 376615813b
15 changed files with 382 additions and 386 deletions

132
Cargo.lock generated
View File

@@ -1260,7 +1260,15 @@ dependencies = [
"gloo-timers",
"js-sys",
"leptos",
"leptos-shadcn-ui",
"leptos-shadcn-avatar",
"leptos-shadcn-badge",
"leptos-shadcn-button",
"leptos-shadcn-card",
"leptos-shadcn-context-menu",
"leptos-shadcn-input",
"leptos-shadcn-progress",
"leptos-shadcn-separator",
"leptos-shadcn-sheet",
"leptos-use",
"leptos_router",
"log",
@@ -2149,6 +2157,35 @@ dependencies = [
"send_wrapper",
]
[[package]]
name = "leptos-shadcn-avatar"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cb3c5b1f5ba02f7282b55fde1513cdfecef3b25bf5fa44e1eb29fcaf8b927c5"
dependencies = [
"leptos",
"leptos-shadcn-signal-management",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "leptos-shadcn-badge"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24578fb0bc21eb21be4e686e6719c7e183acb8fd071a4f81fb27fe452751c88a"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-button"
version = "0.8.1"
@@ -2164,6 +2201,37 @@ dependencies = [
"web-sys",
]
[[package]]
name = "leptos-shadcn-card"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5cda16742d1e20284e5f6805eab88b6e54c1378d1548a8e15a5eedda1ea3eb"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-context-menu"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f440e9a7517dfe6ba758080ddba1dfe42e4697008f60adfc112c5da02dca8d"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "leptos-shadcn-input"
version = "0.8.1"
@@ -2180,6 +2248,51 @@ dependencies = [
"web-sys",
]
[[package]]
name = "leptos-shadcn-progress"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34ca41b8ebfd7f29126e4f8656987834f3613717016f11f3983da85a90669f6"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-separator"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5dfda49f059fd4d1549d663e6743e37a5c6c84d1ac2d6daec32caa3156bc268"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-sheet"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba85819a0c94a7705ed92989442c64cc75d9ed3a4540e711e87c56b206431611"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-signal-management"
version = "0.1.0"
@@ -2194,23 +2307,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "leptos-shadcn-ui"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43430605d3d049a4cf68fb7dff4e6f940426ec48131f4662963f62f11baa3e18"
dependencies = [
"gloo-timers",
"leptos",
"leptos-node-ref",
"leptos-shadcn-button",
"leptos-shadcn-input",
"leptos-struct-component",
"leptos-style",
"leptos_router",
"tailwind_fuse",
]
[[package]]
name = "leptos-struct-component"
version = "0.2.0"

View File

@@ -346,10 +346,7 @@ async fn main() {
match diff::diff_torrents(&previous_torrents, &new_torrents) {
diff::DiffResult::FullUpdate => {
let _ = event_bus_tx.send(AppEvent::FullList {
torrents: new_torrents.clone(),
timestamp: now,
});
let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now));
}
diff::DiffResult::Partial(updates) => {
for update in updates {

View File

@@ -210,10 +210,7 @@ pub async fn sse_handler(
.unwrap()
.as_secs();
let event_data = AppEvent::FullList {
torrents: initial_torrents,
timestamp,
};
let event_data = AppEvent::FullList(initial_torrents, timestamp);
match rmp_serde::to_vec(&event_data) {
Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
@@ -250,7 +247,7 @@ pub async fn sse_handler(
.keep_alive(axum::response::sse::KeepAlive::default());
(
[("content-type", "application/x-msgpack")],
[("content-type", "text/event-stream")],
sse
)
}

View File

@@ -33,4 +33,14 @@ codee = "0.3"
thiserror = "2.0"
rmp-serde = "1.3"
struct-patch = "0.5"
leptos-shadcn-ui = { version = "0.9.0", default-features = false, features = ["button", "input"] }
# ShadCN UI Components (Individual)
leptos-shadcn-button = "0.8"
leptos-shadcn-input = "0.8"
leptos-shadcn-card = "0.8"
leptos-shadcn-badge = "0.8"
leptos-shadcn-context-menu = "0.8"
leptos-shadcn-separator = "0.8"
leptos-shadcn-progress = "0.8"
leptos-shadcn-avatar = "0.8"
leptos-shadcn-sheet = "0.8"

View File

@@ -126,6 +126,14 @@
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 */

View File

@@ -50,18 +50,17 @@
--text-lg--line-height: calc(1.75 / 1.125);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--tracking-tight: -0.025em;
--tracking-wider: 0.05em;
--leading-tight: 1.25;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: 0.75rem;
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite;
--blur-sm: 8px;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
@@ -239,9 +238,6 @@
.pointer-events-auto {
pointer-events: auto;
}
.pointer-events-none {
pointer-events: none;
}
.absolute {
position: absolute;
}
@@ -254,12 +250,15 @@
.static {
position: static;
}
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.inset-y-0 {
inset-block: calc(var(--spacing) * 0);
}
.top-1\/2 {
top: calc(1/2 * 100%);
}
.top-full {
top: 100%;
}
.right-0 {
right: calc(var(--spacing) * 0);
}
@@ -278,8 +277,11 @@
.left-2 {
left: calc(var(--spacing) * 2);
}
.z-10 {
z-index: 10;
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
.z-\[99\] {
z-index: 99;
@@ -305,12 +307,6 @@
max-width: 96rem;
}
}
.my-0\.5 {
margin-block: calc(var(--spacing) * 0.5);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
@@ -413,9 +409,6 @@
.min-h-14 {
min-height: calc(var(--spacing) * 14);
}
.min-h-\[100dvh\] {
min-height: 100dvh;
}
.min-h-screen {
min-height: 100vh;
}
@@ -452,6 +445,9 @@
.w-48 {
width: calc(var(--spacing) * 48);
}
.w-56 {
width: calc(var(--spacing) * 56);
}
.w-64 {
width: calc(var(--spacing) * 64);
}
@@ -470,18 +466,20 @@
.min-w-\[8rem\] {
min-width: 8rem;
}
.min-w-\[10rem\] {
min-width: 10rem;
}
.min-w-\[200px\] {
min-width: 200px;
}
.flex-1 {
flex: 1;
}
.shrink-0 {
flex-shrink: 0;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-x-0 {
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -528,9 +526,6 @@
.justify-start {
justify-content: flex-start;
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
@@ -592,9 +587,6 @@
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
@@ -682,10 +674,10 @@
.bg-background {
background-color: var(--color-background);
}
.bg-background\/95 {
background-color: color-mix(in srgb, hsl(var(--background)) 95%, transparent);
.bg-background\/80 {
background-color: color-mix(in srgb, hsl(var(--background)) 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-background) 95%, transparent);
background-color: color-mix(in oklab, var(--color-background) 80%, transparent);
}
}
.bg-blue-100 {
@@ -802,15 +794,15 @@
.pr-2 {
padding-right: calc(var(--spacing) * 2);
}
.pb-0 {
padding-bottom: calc(var(--spacing) * 0);
}
.pb-2 {
padding-bottom: calc(var(--spacing) * 2);
}
.pb-3 {
padding-bottom: calc(var(--spacing) * 3);
}
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.pl-8 {
padding-left: calc(var(--spacing) * 8);
}
@@ -858,10 +850,6 @@
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-normal {
--tw-font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-normal);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
@@ -870,10 +858,6 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
.tracking-wider {
--tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider);
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -977,25 +961,14 @@
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-xl {
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-2 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-primary {
--tw-ring-color: var(--color-primary);
}
.ring-offset-background {
--tw-ring-offset-color: var(--color-background);
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.backdrop-blur {
--tw-backdrop-blur: blur(8px);
.backdrop-blur-sm {
--tw-backdrop-blur: blur(var(--blur-sm));
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
@@ -1014,14 +987,23 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-100 {
--tw-duration: 100ms;
transition-duration: 100ms;
.transition-transform {
transition-property: transform, translate, scale, rotate;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-300 {
--tw-duration: 300ms;
transition-duration: 300ms;
}
.duration-500 {
--tw-duration: 500ms;
transition-duration: 500ms;
}
.ease-in-out {
--tw-ease: var(--ease-in-out);
transition-timing-function: var(--ease-in-out);
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
@@ -1030,9 +1012,6 @@
-webkit-user-select: none;
user-select: none;
}
.ring-inset {
--tw-ring-inset: inset;
}
.group-open\:block {
&:is(:where(.group):is([open], :popover-open, :open) *) {
display: block;
@@ -1083,6 +1062,13 @@
color: var(--color-muted-foreground);
}
}
.hover\:border-primary {
&:hover {
@media (hover: hover) {
border-color: var(--color-primary);
}
}
}
.hover\:bg-accent {
&:hover {
@media (hover: hover) {
@@ -1107,13 +1093,6 @@
}
}
}
.hover\:bg-primary {
&:hover {
@media (hover: hover) {
background-color: var(--color-primary);
}
}
}
.hover\:bg-primary\/90 {
&:hover {
@media (hover: hover) {
@@ -1160,11 +1139,21 @@
background-color: var(--color-accent);
}
}
.focus\:bg-destructive {
&:focus {
background-color: var(--color-destructive);
}
}
.focus\:text-accent-foreground {
&:focus {
color: var(--color-accent-foreground);
}
}
.focus\:text-destructive-foreground {
&:focus {
color: var(--color-destructive-foreground);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -1297,11 +1286,22 @@
max-width: 420px;
}
}
.lg\:relative {
@media (width >= 64rem) {
position: relative;
}
}
.lg\:hidden {
@media (width >= 64rem) {
display: none;
}
}
.lg\:translate-x-0 {
@media (width >= 64rem) {
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
}
.dark\:border-blue-800 {
@media (prefers-color-scheme: dark) {
border-color: var(--color-blue-800);
@@ -1479,6 +1479,24 @@
background-color: var(--color-background);
color: var(--color-foreground);
}
.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;
}
}
@media (hover: none) {
body {
@@ -1712,6 +1730,10 @@
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -1771,6 +1793,7 @@
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
--tw-duration: initial;
--tw-ease: initial;
}
}
}

View File

@@ -3,7 +3,6 @@ use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup;
use crate::api;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
@@ -122,14 +121,14 @@ pub fn App() -> impl IntoView {
<p class="text-sm text-muted-foreground">"Yükleniyor..."</p>
</div>
</div>
}>
}.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<TorrentTable />
</Protected>
</Show>
</Show>
}
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {

View File

@@ -16,17 +16,13 @@ pub fn Login() -> impl IntoView {
let user = username.0.get();
let pass = password.0.get();
log::info!("Attempting login for user: {}", user);
spawn_local(async move {
match shared::server_fns::auth::login(user, pass).await {
Ok(_) => {
log::info!("Login successful, redirecting...");
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/");
}
Err(e) => {
log::error!("Login failed: {:?}", e);
Err(_) => {
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
loading.1.set(false);
}
@@ -35,7 +31,7 @@ pub fn Login() -> impl IntoView {
};
view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40">
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<div class="w-full max-w-sm rounded-xl border border-border bg-card text-card-foreground shadow-lg">
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
@@ -51,13 +47,11 @@ pub fn Login() -> impl IntoView {
<div class="p-6 pt-4">
<form on:submit=handle_login class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Kullanıcı Adı"
</label>
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
<input
type="text"
placeholder="Kullanıcı adınız"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
@@ -65,13 +59,11 @@ pub fn Login() -> impl IntoView {
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Şifre"
</label>
<label class="text-sm font-medium leading-none">"Şifre"</label>
<input
type="password"
placeholder="******"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
@@ -79,9 +71,9 @@ pub fn Login() -> impl IntoView {
/>
</div>
<Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive dark:border-destructive dark:bg-destructive/50 dark:text-destructive-foreground">
<span>{move || error.0.get().unwrap_or_default()}</span>
<Show when=move || error.0.get().is_some()>
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{move || error.0.get().unwrap_or_default()}
</div>
</Show>

View File

@@ -1,97 +1,84 @@
use leptos::prelude::*;
use leptos::html;
use leptos_use::on_click_outside;
fn handle_action(
hash: String,
action: &str,
on_action: Callback<(String, String)>,
on_close: Callback<()>,
) {
log::info!("ContextMenu: Action '{}' for hash '{}'", action, hash);
on_action.run((action.to_string(), hash));
on_close.run(());
}
use leptos::portal::Portal;
use leptos_shadcn_context_menu::{
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSeparator,
};
#[component]
pub fn ContextMenu(
position: (i32, i32),
pub fn TorrentContextMenu(
children: Children,
torrent_hash: String,
on_close: Callback<()>,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let container_ref = NodeRef::<html::Div>::new();
let _ = on_click_outside(container_ref, move |_| on_close.run(()));
let (x, y) = position;
let hash1 = torrent_hash.clone();
let hash2 = torrent_hash.clone();
let hash3 = torrent_hash.clone();
let hash4 = torrent_hash.clone();
let hash5 = torrent_hash;
let hash = StoredValue::new(torrent_hash);
let on_action = StoredValue::new(on_action);
view! {
<div
node_ref=container_ref
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
style=format!("left: {}px; top: {}px;", x, y)
on:contextmenu=move |e| e.prevent_default()
>
<ul class="menu bg-base-200 shadow-xl rounded-box border border-base-300 p-1 gap-0.5">
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash1.clone(), "start", on_action.clone(), on_close.clone());
<ContextMenu>
<ContextMenuTrigger class="w-full">
{children()}
</ContextMenuTrigger>
<Portal>
<ContextMenuContent class="w-56 z-[100] bg-popover border border-border shadow-md rounded-md p-1">
<ContextMenuItem on:click=move |_| {
on_action.get_value().run(("start".to_string(), hash.get_value()));
}>
<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="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
<span>"Start"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash2.clone(), "stop", on_action.clone(), on_close.clone());
"Start"
</ContextMenuItem>
<ContextMenuItem on:click=move |_| {
on_action.get_value().run(("stop".to_string(), hash.get_value()));
}>
<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="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
<span>"Stop"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash3.clone(), "recheck", on_action.clone(), on_close.clone());
"Stop"
</ContextMenuItem>
<ContextMenuItem on:click=move |_| {
on_action.get_value().run(("recheck".to_string(), hash.get_value()));
}>
<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="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>"Recheck"</span>
</button>
</li>
<div class="divider my-0.5 opacity-50"></div>
<li>
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash4.clone(), "delete", on_action.clone(), on_close.clone());
}>
<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">
"Recheck"
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
class="text-destructive focus:text-destructive-foreground focus:bg-destructive"
on:click=move |_| {
on_action.get_value().run(("delete".to_string(), hash.get_value()));
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span>"Remove"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash5.clone(), "delete_with_data", on_action.clone(), on_close.clone());
}>
<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">
"Remove"
</ContextMenuItem>
<ContextMenuItem
class="text-destructive focus:text-destructive-foreground focus:bg-destructive"
on:click=move |_| {
on_action.get_value().run(("delete_with_data".to_string(), hash.get_value()));
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span>"Remove Data"</span>
</button>
</li>
</ul>
</div>
"Remove Data"
</ContextMenuItem>
</ContextMenuContent>
</Portal>
</ContextMenu>
}
}

View File

@@ -5,28 +5,48 @@ use crate::components::layout::statusbar::StatusBar;
#[component]
pub fn Protected(children: Children) -> impl IntoView {
view! {
<div class="drawer lg:drawer-open h-full w-full">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
let is_mobile_menu_open = RwSignal::new(false);
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100">
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
provide_context(is_mobile_menu_open);
view! {
<div class="flex h-screen w-full overflow-hidden bg-background">
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
<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 />
</aside>
// Mobil arka plan karartma (Overlay)
<Show when=move || is_mobile_menu_open.get()>
<div
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 />
// --- MAIN CONTENT ---
<main class="flex-1 overflow-hidden relative">
<main class="flex-1 overflow-hidden relative bg-background">
{children()}
</main>
// --- STATUS BAR (BOTTOM) ---
<StatusBar />
</div>
// --- SIDEBAR (DRAWER) ---
<div class="drawer-side z-[100]">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<Sidebar />
</div>
</div>
}
}

View File

@@ -1,11 +1,10 @@
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::task::spawn_local;
use crate::api;
#[component]
pub fn Sidebar() -> impl IntoView {
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 downloading_count = move || {
@@ -50,16 +49,9 @@ pub fn Sidebar() -> impl IntoView {
})
};
let close_drawer = move || {
// With Shadcn Sheet, this logic might change, but for now we keep DOM manipulation minimal or handled by parent
if let Some(element) = document().get_element_by_id("mobile-sheet-trigger") {
// Logic to close sheet if open (simulated click or state change)
}
};
let set_filter = move |f: crate::store::FilterStatus| {
store.filter.set(f);
close_drawer();
is_mobile_menu_open.set(false);
};
let filter_class = move |f: crate::store::FilterStatus| {
@@ -73,7 +65,7 @@ pub fn Sidebar() -> impl IntoView {
let handle_logout = move |_| {
spawn_local(async move {
if api::auth::logout().await.is_ok() {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
@@ -89,7 +81,7 @@ pub fn Sidebar() -> impl IntoView {
};
view! {
<div class="w-64 min-h-[100dvh] flex flex-col bg-card border-r border-border pb-8" style="padding-top: env(safe-area-inset-top);">
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
<div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
"VibeTorrent"

View File

@@ -5,12 +5,16 @@ use crate::components::torrent::add_torrent::AddTorrentDialog;
pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false);
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");
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 flex-1 items-center gap-4">
// Mobile Menu Trigger (Sheet Trigger in full impl)
<button id="mobile-sheet-trigger" class="lg:hidden 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-10 w-10">
// Mobile Menu Trigger
<button
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
class="lg:hidden 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-10 w-10"
>
<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>
</button>

View File

@@ -1,10 +1,10 @@
use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local;
use leptos_use::{use_timeout_fn, UseTimeoutFnReturn};
use crate::store::{get_action_messages, show_toast_with_signal};
use crate::api;
use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu;
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -50,7 +50,7 @@ pub fn TorrentTable() -> impl IntoView {
let sort_col = signal(SortColumn::AddedDate);
let sort_dir = signal(SortDirection::Descending);
let filtered_hashes = move || {
let filtered_hashes = Memo::new(move |_| {
let torrents_map = store.torrents.get();
let filter = store.filter.get();
let search = store.search_query.get();
@@ -90,7 +90,7 @@ pub fn TorrentTable() -> impl IntoView {
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
});
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
};
});
let handle_sort = move |col: SortColumn| {
if sort_col.0.get() == col {
@@ -103,8 +103,6 @@ pub fn TorrentTable() -> impl IntoView {
}
};
let sort_details_ref = NodeRef::<html::Details>::new();
let sort_arrow = move |col: SortColumn| {
if sort_col.0.get() == col {
match sort_dir.0.get() {
@@ -114,18 +112,7 @@ pub fn TorrentTable() -> impl IntoView {
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">""</span> }.into_any() }
};
let selected_hash = signal(Option::<String>::None);
let menu_visible = signal(false);
let menu_position = signal((0, 0));
let handle_context_menu = move |e: web_sys::MouseEvent, hash: String| {
e.prevent_default();
menu_position.1.set((e.client_x(), e.client_y()));
selected_hash.1.set(Some(hash));
menu_visible.1.set(true);
};
let on_action = move |(action, hash): (String, String)| {
let on_action = Callback::new(move |(action, hash): (String, String)| {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string();
let error_msg = error_msg_str.to_string();
@@ -143,10 +130,10 @@ pub fn TorrentTable() -> impl IntoView {
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
}
});
};
});
view! {
<div class="h-full bg-background relative flex flex-col">
<div class="h-full bg-background relative flex flex-col overflow-hidden">
// --- DESKTOP VIEW ---
<div class="hidden md:flex flex-col h-full overflow-hidden">
// Header
@@ -177,18 +164,16 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
// Regular List (Standard For loop)
// Regular List
<div class="flex-1 overflow-y-auto min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<TorrentRow
hash=hash.clone()
selected_hash=selected_hash.0
set_selected_hash=selected_hash.1
on_context_menu=handle_context_menu.clone()
/>
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentRow hash=hash.clone() />
</TorrentContextMenu>
}
}
} />
@@ -197,61 +182,22 @@ pub fn TorrentTable() -> impl IntoView {
// --- MOBILE VIEW ---
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
<div class="px-3 py-2 border-b border-border flex justify-between items-center bg-background/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider text-muted-foreground">"Torrents"</span>
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-sm px-2 py-1 flex items-center">
<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 pointer-events-none"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>
<span class="pointer-events-none">"Sort"</span>
</summary>
<div class="dropdown-content z-[100] absolute right-0 top-full mt-1 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">"Sort By"</div>
<ul class="w-full">
{
let columns = vec![(SortColumn::Name, "Name"), (SortColumn::Size, "Size"), (SortColumn::Progress, "Progress"), (SortColumn::Status, "Status"), (SortColumn::DownSpeed, "DL Speed"), (SortColumn::UpSpeed, "Up Speed"), (SortColumn::ETA, "ETA"), (SortColumn::AddedDate, "Date")];
columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.0.get() == col;
view! {
<li>
<button type="button" class=move || if is_active() { "bg-accent text-accent-foreground font-medium flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none" } else { "flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none hover:bg-accent hover:text-accent-foreground" } on:click=move |_| { handle_sort(col); if let Some(el) = sort_details_ref.get() { el.set_open(false); } }>
{label}
<Show when=is_active fallback=|| ()><span class="ml-auto opacity-70 text-[10px]">{move || match sort_dir.0.get() { SortDirection::Ascending => "", SortDirection::Descending => "" }}</span></Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
</div>
<div class="flex-1 overflow-y-auto p-3 min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
let menu_pos_setter = menu_position.1.clone();
let menu_vis_setter = menu_visible.1.clone();
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<div class="pb-3">
<TorrentCard
hash=hash.clone()
selected_hash=selected_hash.0
set_selected_hash=selected_hash.1
set_menu_position=menu_pos_setter
set_menu_visible=menu_vis_setter
on_context_menu=handle_context_menu.clone()
/>
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentCard hash=hash.clone() />
</TorrentContextMenu>
</div>
}
}
} />
</div>
</div>
<Show when=move || menu_visible.0.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu position=menu_position.0.get() torrent_hash=selected_hash.0.get().unwrap_or_default() on_close=Callback::new(move |()| menu_visible.1.set(false)) on_action=Callback::new(move |args| on_action(args)) />
</Show>
</div>
}
}
@@ -259,9 +205,6 @@ pub fn TorrentTable() -> impl IntoView {
#[component]
fn TorrentRow(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -270,34 +213,13 @@ fn TorrentRow(
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let on_context_menu = on_context_menu.clone();
let hash = hash.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.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 selected_hash_clone = selected_hash.clone();
let t_hash_row = t_hash.clone();
view! {
<div
class=move || {
let base = "flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer";
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-muted", base) } else { base.to_string() }
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash.clone();
let set_selected_hash = set_selected_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
>
<div class="flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full">
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
<div class="w-48 px-2">
@@ -324,11 +246,6 @@ fn TorrentRow(
#[component]
fn TorrentCard(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
set_menu_position: WriteSignal<(i32, i32)>,
set_menu_visible: WriteSignal<bool>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -337,57 +254,20 @@ fn TorrentCard(
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let hash = hash.clone();
let on_context_menu = on_context_menu.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.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 t_hash_long = t_hash.clone();
let set_menu_position = set_menu_position.clone();
let set_selected_hash = set_selected_hash.clone();
let set_menu_visible = set_menu_visible.clone();
let UseTimeoutFnReturn { start, .. } = use_timeout_fn(
move |pos: (i32, i32)| {
set_menu_position.set(pos);
set_selected_hash.set(Some(t_hash_long.clone()));
set_menu_visible.set(true);
let _ = window().navigator().vibrate_with_duration(50);
},
600.0,
);
let selected_hash_clone = selected_hash.clone();
let t_hash_card = t_hash.clone();
view! {
<div
class=move || {
let base = "bg-card text-card-foreground rounded-lg border border-border shadow-sm select-none cursor-pointer h-full";
if selected_hash_clone.get() == Some(t_hash_card.clone()) { format!("{} ring-2 ring-primary ring-inset", base) } else { base.to_string() }
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash.clone();
let set_selected_hash = set_selected_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
on:touchstart={
let start = start.clone();
move |e: web_sys::TouchEvent| if let Some(touch) = e.touches().get(0) { start((touch.client_x(), touch.client_y())); }
}
>
<div class="p-3 gap-3 flex flex-col h-full justify-between">
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
<CardHeader class="p-3 pb-0">
<div class="flex justify-between items-start gap-2">
<h3 class="font-medium text-sm line-clamp-2 leading-tight">{t_name.clone()}</h3>
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
</div>
</CardHeader>
<CardContent 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>
@@ -403,8 +283,8 @@ fn TorrentCard(
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
</div>
</div>
</div>
</CardContent>
</Card>
}
}
}

View File

@@ -124,7 +124,7 @@ pub fn provide_torrent_store() {
match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => {
match event {
AppEvent::FullList { torrents: list, .. } => {
AppEvent::FullList(list, _) => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
@@ -136,14 +136,15 @@ pub fn provide_torrent_store() {
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
}
AppEvent::Update(patch) => {
let hash_opt = patch.hash.clone();
if let Some(hash) = hash_opt {
torrents_for_sse.update(|map| {
if let Some(hash) = patch.hash.as_ref() {
if let Some(t) = map.get_mut(hash) {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch);
}
}
});
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());

View File

@@ -53,20 +53,10 @@ pub enum TorrentStatus {
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(tag = "t", content = "d")]
pub enum AppEvent {
#[serde(rename = "f")]
FullList {
#[serde(rename = "t")]
torrents: Vec<Torrent>,
#[serde(rename = "ts")]
timestamp: u64,
},
#[serde(rename = "u")]
FullList(Vec<Torrent>, u64),
Update(TorrentUpdate),
#[serde(rename = "s")]
Stats(GlobalStats),
#[serde(rename = "n")]
Notification(SystemNotification),
}