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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
118
frontend/src/components/torrent/add_torrent.rs
Normal file
118
frontend/src/components/torrent/add_torrent.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod table;
|
||||
pub mod add_torrent;
|
||||
|
||||
@@ -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); // 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);
|
||||
}
|
||||
set_menu_visible.set(false);
|
||||
}
|
||||
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>
|
||||
|
||||
<Show when=move || menu_visible.get() fallback=|| ()>
|
||||
<crate::components::context_menu::ContextMenu
|
||||
visible=menu_visible.get()
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user