feat: finalize shadcn integration with portal-based context menu and clean build
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
This commit is contained in:
132
Cargo.lock
generated
132
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user