feat: Implement Context Menu actions and Add Torrent modal

- Implemented Start, Stop, Delete actions in context menu using backend API.
- Replaced  with manual  implementation for context menu reliability.
- Added  for adding torrents via Magnet Link/URL.
- Integrated Add Torrent feature into the Toolbar.
This commit is contained in:
spinline
2026-01-31 20:40:42 +03:00
parent 432bc7b9e9
commit e932fa1e39
6 changed files with 347 additions and 18 deletions

View File

@@ -20,6 +20,19 @@ wasm-bindgen = "0.2"
uuid = { version = "1", features = ["v4", "js"] }
futures = "0.3"
chrono = { version = "0.4", features = ["serde"] }
web-sys = { version = "0.3", features = ["Window", "Storage", "Document", "Element"] }
web-sys = { version = "0.3", features = [
"HtmlDivElement",
"HtmlUListElement",
"HtmlLiElement",
"HtmlAnchorElement",
"MouseEvent",
"Event",
"Window",
"Document",
"Element",
"DomTokenList",
"CssStyleDeclaration",
"Storage"
] }
shared = { path = "../shared" }
tailwind_fuse = "0.3.2"

View File

@@ -192,6 +192,61 @@
}
}
@layer utilities {
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
visibility: hidden;
position: fixed;
inset: calc(0.25rem * 0);
margin: calc(0.25rem * 0);
display: grid;
height: 100%;
max-height: none;
width: 100%;
max-width: none;
align-items: center;
justify-items: center;
background-color: transparent;
padding: calc(0.25rem * 0);
color: inherit;
transition: visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
overflow: clip;
overscroll-behavior: contain;
z-index: 999;
scrollbar-gutter: auto;
&::backdrop {
display: none;
}
}
@layer daisyui.l1.l2 {
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
pointer-events: auto;
visibility: visible;
opacity: 100%;
transition: visibility 0s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
background-color: oklch(0% 0 0/ 0.4);
.modal-box {
translate: 0 0;
scale: 1;
opacity: 1;
}
:root:has(&) {
--page-has-backdrop: 1;
--page-overflow: hidden;
--page-scroll-bg: var(--page-scroll-bg-on);
--page-scroll-gutter: stable;
--page-scroll-transition: var(--page-scroll-transition-on);
animation: set-page-has-scroll forwards;
animation-timeline: scroll();
}
}
@starting-style {
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
opacity: 0%;
}
}
}
}
.drawer-side {
:where(&) {
@layer daisyui.l1.l2.l3 {
@@ -591,6 +646,20 @@
}
}
}
.loading {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
display: inline-block;
aspect-ratio: 1 / 1;
background-color: currentcolor;
vertical-align: middle;
width: calc(var(--size-selector, 0.25rem) * 6);
mask-size: 100%;
mask-repeat: no-repeat;
mask-position: center;
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
}
}
.\!visible {
visibility: visible !important;
}
@@ -941,6 +1010,20 @@
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.modal-backdrop {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
grid-row-start: 1;
display: grid;
align-self: stretch;
justify-self: stretch;
color: transparent;
z-index: -1;
button {
cursor: pointer;
}
}
}
.z-40 {
z-index: 40;
}
@@ -950,6 +1033,27 @@
.z-\[200\] {
z-index: 200;
}
.modal-box {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
grid-row-start: 1;
max-height: 100vh;
width: calc(11/12 * 100%);
max-width: 32rem;
background-color: var(--color-base-100);
padding: calc(0.25rem * 6);
transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out;
border-top-left-radius: var(--modal-tl, var(--radius-box));
border-top-right-radius: var(--modal-tr, var(--radius-box));
border-bottom-left-radius: var(--modal-bl, var(--radius-box));
border-bottom-right-radius: var(--modal-br, var(--radius-box));
scale: 95%;
opacity: 0;
box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
overflow-y: auto;
overscroll-behavior: contain;
}
}
.drawer-content {
@layer daisyui.l1.l2.l3 {
grid-column-start: 2;
@@ -1065,6 +1169,17 @@
border-width: var(--border, 1px) 0 var(--border, 1px) var(--border, 1px);
}
}
.modal-action {
@layer daisyui.l1.l2.l3 {
margin-top: calc(0.25rem * 6);
display: flex;
justify-content: flex-end;
gap: calc(0.25rem * 2);
}
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mt-auto {
margin-top: auto;
}
@@ -1207,6 +1322,23 @@
.table {
display: table;
}
.modal-bottom {
@layer daisyui.l1.l2 {
place-items: end;
.modal-box {
height: auto;
width: 100%;
max-width: none;
max-height: calc(100vh - 5em);
translate: 0 100%;
scale: 1;
--modal-tl: var(--radius-box);
--modal-tr: var(--radius-box);
--modal-bl: 0;
--modal-br: 0;
}
}
}
.btn-square {
@layer daisyui.l1.l2 {
padding-inline: calc(0.25rem * 0);
@@ -1413,6 +1545,11 @@
background-color: color-mix(in oklab, var(--color-white) 10%, transparent);
}
}
.loading-spinner {
@layer daisyui.l1.l2 {
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
}
}
.stroke-current {
stroke: currentcolor;
}
@@ -1464,6 +1601,9 @@
.py-2\.5 {
padding-block: calc(var(--spacing) * 2.5);
}
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.text-left {
text-align: left;
}
@@ -1686,6 +1826,12 @@
--size: calc(var(--size-field, 0.25rem) * 6);
}
}
.btn-primary {
@layer daisyui.l1.l2 {
--btn-color: var(--color-primary);
--btn-fg: var(--color-primary-content);
}
}
.select-none {
-webkit-user-select: none;
user-select: none;
@@ -1793,6 +1939,25 @@
opacity: 50%;
}
}
.sm\:modal-middle {
@media (width >= 40rem) {
@layer daisyui.l1.l2 {
place-items: center;
.modal-box {
height: auto;
width: calc(11/12 * 100%);
max-width: 32rem;
max-height: calc(100vh - 5em);
translate: 0 2%;
scale: 98%;
--modal-tl: var(--radius-box);
--modal-tr: var(--radius-box);
--modal-bl: var(--radius-box);
--modal-br: var(--radius-box);
}
}
}
}
.sm\:p-4 {
@media (width >= 40rem) {
padding: calc(var(--spacing) * 4);

View File

@@ -2,6 +2,8 @@ use leptos::*;
#[component]
pub fn Toolbar() -> impl IntoView {
let (show_add_modal, set_show_add_modal) = create_signal(false);
view! {
<div class="h-14 min-h-14 flex items-center px-4 border-b border-base-300 bg-base-100 gap-4">
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
@@ -14,7 +16,11 @@ pub fn Toolbar() -> impl IntoView {
</svg>
"Open"
</button>
<button class="join-item btn btn-sm btn-outline gap-2" title="Magnet Link">
<button
class="join-item btn btn-sm btn-outline gap-2"
title="Magnet Link"
on:click=move |_| set_show_add_modal.set(true)
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
@@ -46,6 +52,10 @@ pub fn Toolbar() -> impl IntoView {
<div class="ml-auto flex items-center gap-2">
<input type="text" placeholder="Filter..." class="input input-sm input-bordered w-full max-w-xs" />
</div>
<Show when=move || show_add_modal.get()>
<crate::components::torrent::add_torrent::AddTorrentModal on_close=move |_| set_show_add_modal.set(false) />
</Show>
</div>
}
}

View File

@@ -0,0 +1,118 @@
use leptos::*;
use leptos::html::Dialog;
#[component]
pub fn AddTorrentModal(
#[prop(into)]
on_close: Callback<()>,
) -> impl IntoView {
let dialog_ref = create_node_ref::<Dialog>();
let (uri, set_uri) = create_signal(String::new());
let (is_loading, set_loading) = create_signal(false);
let (error_msg, set_error_msg) = create_signal(Option::<String>::None);
// Effect to open the dialog when the component mounts/renders
create_effect(move |_| {
if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal();
}
});
let handle_submit = move |_| {
let uri_val = uri.get();
if uri_val.is_empty() {
set_error_msg.set(Some("Please enter a Magnet URI or URL".to_string()));
return;
}
set_loading.set(true);
set_error_msg.set(None);
spawn_local(async move {
let req_body = serde_json::json!({
"uri": uri_val
});
match gloo_net::http::Request::post("/api/torrents/add")
.json(&req_body)
{
Ok(req) => {
match req.send().await {
Ok(resp) => {
if resp.ok() {
logging::log!("Torrent added successfully");
set_loading.set(false);
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.call(());
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
logging::error!("Failed to add torrent: {} - {}", status, text);
set_error_msg.set(Some(format!("Error {}: {}", status, text)));
set_loading.set(false);
}
}
Err(e) => {
logging::error!("Network error: {}", e);
set_error_msg.set(Some(format!("Network Error: {}", e)));
set_loading.set(false);
}
}
}
Err(e) => {
logging::error!("Serialization error: {}", e);
set_error_msg.set(Some(format!("Request Error: {}", e)));
set_loading.set(false);
}
}
});
};
let handle_close = move |_| {
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.call(());
};
view! {
<dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle" on:close=move |_| on_close.call(())>
<div class="modal-box">
<h3 class="font-bold text-lg">"Add Torrent"</h3>
<p class="py-4">"Enter a Magnet URI or direct URL to a .torrent file."</p>
<div class="form-control w-full">
<input
type="text"
placeholder="magnet:?xt=urn:btih:..."
class="input input-bordered w-full"
prop:value=uri
on:input=move |ev| set_uri.set(event_target_value(&ev))
disabled=is_loading
/>
</div>
<div class="modal-action">
<button class="btn" on:click=handle_close disabled=is_loading>"Cancel"</button>
<button class="btn btn-primary" on:click=handle_submit disabled=is_loading>
{move || if is_loading.get() {
view! { <span class="loading loading-spinner"></span> "Adding..." }.into_view()
} else {
view! { "Add" }.into_view()
}}
</button>
</div>
{move || error_msg.get().map(|msg| view! {
<div class="text-error text-sm mt-2">{msg}</div>
})}
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" on:click=handle_close>"close"</button>
</form>
</dialog>
}
}

View File

@@ -1 +1,2 @@
pub mod table;
pub mod add_torrent;

View File

@@ -119,15 +119,35 @@ pub fn TorrentTable() -> impl IntoView {
let on_action = move |(action, hash): (String, String)| {
logging::log!("TorrentTable Action: {} on {}", action, hash);
// TODO: Implement actual store calls here (start/stop/delete)
match action.as_str() {
"start" => { /* store.start_torrent(&hash) */ },
"stop" => { /* store.stop_torrent(&hash) */ },
"delete" => { /* store.delete_torrent(&hash, false) */ },
"delete_with_data" => { /* store.delete_torrent(&hash, true) */ },
_ => {}
}
set_menu_visible.set(false);
set_menu_visible.set(false); // Close menu immediately
spawn_local(async move {
let action_req = if action == "delete_with_data" { "delete_with_data" } else { &action };
let req_body = shared::TorrentActionRequest {
hash: hash.clone(),
action: action_req.to_string(),
};
let client = gloo_net::http::Request::post("/api/torrents/action")
.json(&req_body);
match client {
Ok(req) => {
match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!("Failed to execute action: {} {}", resp.status(), resp.status_text());
} else {
logging::log!("Action {} executed successfully", action);
}
}
Err(e) => logging::error!("Network error executing action: {}", e),
}
}
Err(e) => logging::error!("Failed to serialize request: {}", e),
}
});
};
view! {
@@ -209,13 +229,15 @@ pub fn TorrentTable() -> impl IntoView {
</tbody>
</table>
<crate::components::context_menu::ContextMenu
visible=menu_visible.get()
position=menu_position.get()
torrent_hash=active_hash.get()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
<Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=active_hash.get()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
</Show>
</div>
}
}