diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 8463cf8..5647744 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -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" diff --git a/frontend/public/tailwind.css b/frontend/public/tailwind.css index 364acde..b7cb82e 100644 --- a/frontend/public/tailwind.css +++ b/frontend/public/tailwind.css @@ -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); diff --git a/frontend/src/components/layout/toolbar.rs b/frontend/src/components/layout/toolbar.rs index 138067d..0313791 100644 --- a/frontend/src/components/layout/toolbar.rs +++ b/frontend/src/components/layout/toolbar.rs @@ -2,6 +2,8 @@ use leptos::*; #[component] pub fn Toolbar() -> impl IntoView { + let (show_add_modal, set_show_add_modal) = create_signal(false); + view! {
} } diff --git a/frontend/src/components/torrent/add_torrent.rs b/frontend/src/components/torrent/add_torrent.rs new file mode 100644 index 0000000..2f51cca --- /dev/null +++ b/frontend/src/components/torrent/add_torrent.rs @@ -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::(); + 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::::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! { + + + + + } +} diff --git a/frontend/src/components/torrent/mod.rs b/frontend/src/components/torrent/mod.rs index 13971b0..438f1c3 100644 --- a/frontend/src/components/torrent/mod.rs +++ b/frontend/src/components/torrent/mod.rs @@ -1 +1,2 @@ pub mod table; +pub mod add_torrent; diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index d7f5b14..b9afae6 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -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 { - + + + } }