shares.rs
| 1 | //! Share creation dialog and the "Your shares" management panel (milestone 6). |
| 2 | |
| 3 | use leptos::prelude::*; |
| 4 | use wasm_bindgen::JsCast; |
| 5 | use wasm_bindgen_futures::spawn_local; |
| 6 | |
| 7 | use crate::api::{self, ShareInfo}; |
| 8 | use crate::components::toast::{ToastMsg, show}; |
| 9 | use crate::router::share_url; |
| 10 | use crate::util::format_date; |
| 11 | |
| 12 | // --------------------------------------------------------------------------- |
| 13 | // Expiry helpers (compute the RFC 3339 expiry client-side) |
| 14 | // --------------------------------------------------------------------------- |
| 15 | |
| 16 | /// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601). |
| 17 | fn ms_to_rfc3339(ms: f64) -> Option<String> { |
| 18 | if ms.is_nan() { |
| 19 | return None; |
| 20 | } |
| 21 | Some( |
| 22 | js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms)) |
| 23 | .to_iso_string() |
| 24 | .into(), |
| 25 | ) |
| 26 | } |
| 27 | |
| 28 | /// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for |
| 29 | /// "never". Presets are computed from "now"; "custom" parses the |
| 30 | /// datetime-local value as the browser's local time. |
| 31 | fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> { |
| 32 | let now = js_sys::Date::now(); |
| 33 | let h = 60.0 * 60.0 * 1000.0; |
| 34 | let target_ms: Option<f64> = match choice { |
| 35 | "never" => return None, |
| 36 | "1h" => Some(now + h), |
| 37 | "1d" => Some(now + 24.0 * h), |
| 38 | "1w" => Some(now + 7.0 * 24.0 * h), |
| 39 | "1mo" => Some(now + 30.0 * 24.0 * h), |
| 40 | "custom" => { |
| 41 | if custom.is_empty() { |
| 42 | None |
| 43 | } else { |
| 44 | // `Date.parse` treats a bare datetime as local time, so the |
| 45 | // result is already the correct UTC instant. |
| 46 | Some(js_sys::Date::parse(custom)) |
| 47 | } |
| 48 | } |
| 49 | _ => return None, |
| 50 | }; |
| 51 | target_ms.and_then(ms_to_rfc3339) |
| 52 | } |
| 53 | |
| 54 | fn copy_to_clipboard(text: &str, toast: ToastMsg) { |
| 55 | let Some(win) = web_sys::window() else { |
| 56 | show(toast, "Copy the link manually".to_string()); |
| 57 | return; |
| 58 | }; |
| 59 | let cb = win.navigator().clipboard(); |
| 60 | let t = text.to_string(); |
| 61 | spawn_local(async move { |
| 62 | let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await; |
| 63 | show(toast, "Link copied to clipboard".to_string()); |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | fn select_value(ev: &web_sys::Event) -> Option<String> { |
| 68 | ev.target() |
| 69 | .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok()) |
| 70 | .map(|s| s.value()) |
| 71 | } |
| 72 | |
| 73 | // --------------------------------------------------------------------------- |
| 74 | // Share creation dialog (two-phase: form -> link) |
| 75 | // --------------------------------------------------------------------------- |
| 76 | |
| 77 | #[component] |
| 78 | pub fn ShareDialog( |
| 79 | name: String, |
| 80 | root_id: i64, |
| 81 | path: String, |
| 82 | allow_writable: bool, |
| 83 | close: Callback<()>, |
| 84 | ) -> impl IntoView { |
| 85 | let toast = use_context::<ToastMsg>().expect("toast context"); |
| 86 | let (writable, set_writable) = signal(false); |
| 87 | let (choice, set_choice) = signal("never".to_string()); |
| 88 | let (custom, set_custom) = signal(String::new()); |
| 89 | let (created, set_created) = signal(Option::<ShareInfo>::None); |
| 90 | let (busy, set_busy) = signal(false); |
| 91 | |
| 92 | let do_create = move |_| { |
| 93 | if busy.get() { |
| 94 | return; |
| 95 | } |
| 96 | set_busy.set(true); |
| 97 | let exp = expiry_to_rfc3339(&choice.get(), &custom.get()); |
| 98 | let wr = writable.get(); |
| 99 | let rid = root_id; |
| 100 | let p = path.clone(); |
| 101 | spawn_local(async move { |
| 102 | match api::create_share(rid, &p, wr, exp.as_deref()).await { |
| 103 | Ok(info) => set_created.set(Some(info)), |
| 104 | Err(e) => { |
| 105 | show(toast, format!("Could not create share: {e}")); |
| 106 | set_busy.set(false); |
| 107 | } |
| 108 | } |
| 109 | }); |
| 110 | }; |
| 111 | |
| 112 | view! { |
| 113 | <div class="modal-overlay" on:click=move |_| close.run(())> |
| 114 | <div |
| 115 | class="modal share-modal" |
| 116 | on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation() |
| 117 | > |
| 118 | {move || match created.get() { |
| 119 | Some(ref info) => { |
| 120 | let link = share_url(&info.token); |
| 121 | view! { |
| 122 | <h2 class="modal-title">"Share link"</h2> |
| 123 | <p class="modal-message"> |
| 124 | "Anyone with this link can " |
| 125 | {if writable.get() { "read and write " } else { "read " }} |
| 126 | {format!("“{name}”.")} |
| 127 | </p> |
| 128 | <div class="share-link-row"> |
| 129 | <input |
| 130 | class="share-link-input" |
| 131 | readonly=true |
| 132 | value=link.clone() |
| 133 | on:click=move |ev: web_sys::MouseEvent| { |
| 134 | if let Some(i) = ev |
| 135 | .target() |
| 136 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 137 | { |
| 138 | i.select(); |
| 139 | } |
| 140 | } |
| 141 | /> |
| 142 | <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>"Copy"</button> |
| 143 | </div> |
| 144 | <p class="muted share-expiry-note"> |
| 145 | {match info.expires_at { |
| 146 | Some(ref e) => format!("Expires {0}", format_date(e)), |
| 147 | None => "Never expires".to_string(), |
| 148 | }} |
| 149 | </p> |
| 150 | <div class="modal-actions"> |
| 151 | <button class="btn btn-primary" on:click=move |_| close.run(())>"Done"</button> |
| 152 | </div> |
| 153 | } |
| 154 | .into_view() |
| 155 | .into_any() |
| 156 | } |
| 157 | None => view! { |
| 158 | <h2 class="modal-title">{format!("Share {name}")}</h2> |
| 159 | <p class="modal-message">"Create a link for this item. Writable links let viewers upload and edit."</p> |
| 160 | {move || if allow_writable { |
| 161 | view! { |
| 162 | <label class="share-check"> |
| 163 | <input |
| 164 | type="checkbox" |
| 165 | checked=move || writable.get() |
| 166 | on:change=move |ev: web_sys::Event| { |
| 167 | if let Some(t) = ev |
| 168 | .target() |
| 169 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 170 | { |
| 171 | set_writable.set(t.checked()); |
| 172 | } |
| 173 | } |
| 174 | /> |
| 175 | "Allow editing (read-write)" |
| 176 | </label> |
| 177 | } |
| 178 | .into_view() |
| 179 | .into_any() |
| 180 | } else { |
| 181 | view! { |
| 182 | <p class="muted">"Writable shares are disabled by the server."</p> |
| 183 | } |
| 184 | .into_view() |
| 185 | .into_any() |
| 186 | }} |
| 187 | <div class="share-expiry-row"> |
| 188 | <label for="share-expiry">"Expires"</label> |
| 189 | <select |
| 190 | id="share-expiry" |
| 191 | on:change=move |ev: web_sys::Event| { |
| 192 | if let Some(v) = select_value(&ev) { |
| 193 | set_choice.set(v); |
| 194 | } |
| 195 | } |
| 196 | > |
| 197 | <option value="never">"Never"</option> |
| 198 | <option value="1h">"1 hour"</option> |
| 199 | <option value="1d">"1 day"</option> |
| 200 | <option value="1w">"1 week"</option> |
| 201 | <option value="1mo">"1 month"</option> |
| 202 | <option value="custom">"Custom…"</option> |
| 203 | </select> |
| 204 | </div> |
| 205 | {move || if choice.get() == "custom" { |
| 206 | view! { |
| 207 | <div class="share-expiry-row"> |
| 208 | <label for="share-custom-dt">"Until"</label> |
| 209 | <input |
| 210 | type="datetime-local" |
| 211 | id="share-custom-dt" |
| 212 | value=move || custom.get() |
| 213 | on:change=move |ev: web_sys::Event| { |
| 214 | if let Some(t) = ev |
| 215 | .target() |
| 216 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 217 | { |
| 218 | set_custom.set(t.value()); |
| 219 | } |
| 220 | } |
| 221 | /> |
| 222 | </div> |
| 223 | } |
| 224 | .into_view() |
| 225 | .into_any() |
| 226 | } else { |
| 227 | view! {}.into_any() |
| 228 | }} |
| 229 | <div class="modal-actions"> |
| 230 | <button class="btn" on:click=move |_| close.run(())>"Cancel"</button> |
| 231 | <button |
| 232 | class="btn btn-primary" |
| 233 | disabled=move || busy.get() |
| 234 | on:click=do_create.clone() |
| 235 | > |
| 236 | {move || if busy.get() { "Creating…" } else { "Create link" }} |
| 237 | </button> |
| 238 | </div> |
| 239 | } |
| 240 | .into_view() |
| 241 | .into_any(), |
| 242 | }} |
| 243 | </div> |
| 244 | </div> |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // --------------------------------------------------------------------------- |
| 249 | // "Your shares" management panel |
| 250 | // --------------------------------------------------------------------------- |
| 251 | |
| 252 | #[component] |
| 253 | pub fn SharesPanel(close: Callback<()>) -> impl IntoView { |
| 254 | let toast = use_context::<ToastMsg>().expect("toast context"); |
| 255 | let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None); |
| 256 | { |
| 257 | let set = set_shares; |
| 258 | let toast2 = toast; |
| 259 | spawn_local(async move { |
| 260 | match api::list_shares().await { |
| 261 | Ok(s) => set.set(Some(s)), |
| 262 | Err(e) => show(toast2, e.to_string()), |
| 263 | } |
| 264 | }); |
| 265 | } |
| 266 | |
| 267 | // Escape closes the panel. |
| 268 | { |
| 269 | let c = close; |
| 270 | crate::util::owned_window_listener( |
| 271 | leptos::ev::keydown, |
| 272 | move |ev: web_sys::KeyboardEvent| { |
| 273 | if ev.key() == "Escape" { |
| 274 | c.run(()); |
| 275 | } |
| 276 | }, |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | view! { |
| 281 | <div class="modal-overlay" on:click=move |_| close.run(())> |
| 282 | <div |
| 283 | class="modal shares-panel" |
| 284 | on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation() |
| 285 | > |
| 286 | <h2 class="modal-title">"Your shares"</h2> |
| 287 | {move || match shares.get() { |
| 288 | None => view! { |
| 289 | <p class="muted">"Loading…"</p> |
| 290 | } |
| 291 | .into_view() |
| 292 | .into_any(), |
| 293 | Some(ref list) if list.is_empty() => view! { |
| 294 | <p class="muted">"No shares yet. Right-click a file or folder and choose “Share”."</p> |
| 295 | } |
| 296 | .into_view() |
| 297 | .into_any(), |
| 298 | Some(ref list) => { |
| 299 | let items = list |
| 300 | .iter() |
| 301 | .map(|s| { |
| 302 | let s2 = s.clone(); |
| 303 | let link = share_url(&s.token); |
| 304 | let mut meta = format!( |
| 305 | "{} · {}", |
| 306 | s.target, |
| 307 | if s.writable { "read-write" } else { "read-only" } |
| 308 | ); |
| 309 | if let Some(e) = &s.expires_at { |
| 310 | meta.push_str(&format!(" · expires {}", format_date(e))); |
| 311 | } |
| 312 | meta.push_str(&format!(" · created {}", format_date(&s.created_at))); |
| 313 | view! { |
| 314 | <div class="share-item"> |
| 315 | <div class="share-item-main"> |
| 316 | <div class="share-item-name"> |
| 317 | {if s.is_file { "📄" } else { "📁" }} |
| 318 | {" "}{s.name.clone()} |
| 319 | </div> |
| 320 | <div class="share-item-meta muted">{meta}</div> |
| 321 | </div> |
| 322 | <div class="share-item-actions"> |
| 323 | <button |
| 324 | class="btn btn-sm" |
| 325 | title="Copy link" |
| 326 | on:click=move |_| copy_to_clipboard(&link, toast) |
| 327 | >"Copy"</button> |
| 328 | <button |
| 329 | class="btn btn-sm btn-danger" |
| 330 | title="Delete share" |
| 331 | on:click=move |_| { |
| 332 | let id = s2.id; |
| 333 | let read = shares; |
| 334 | let write = set_shares; |
| 335 | let toast2 = toast; |
| 336 | spawn_local(async move { |
| 337 | if api::delete_share(id).await.is_ok() { |
| 338 | if let Some(mut cur) = read.get() { |
| 339 | cur.retain(|x| x.id != id); |
| 340 | write.set(Some(cur)); |
| 341 | } |
| 342 | show(toast2, "Share deleted"); |
| 343 | } else { |
| 344 | show(toast2, "Could not delete share".to_string()); |
| 345 | } |
| 346 | }); |
| 347 | } |
| 348 | >"Delete"</button> |
| 349 | </div> |
| 350 | </div> |
| 351 | } |
| 352 | }) |
| 353 | .collect::<Vec<_>>(); |
| 354 | view! { |
| 355 | <div class="share-list">{items}</div> |
| 356 | } |
| 357 | } |
| 358 | .into_view() |
| 359 | .into_any(), |
| 360 | }} |
| 361 | <div class="modal-actions"> |
| 362 | <button class="btn" on:click=move |_| close.run(())>"Close"</button> |
| 363 | </div> |
| 364 | </div> |
| 365 | </div> |
| 366 | } |
| 367 | } |
| 368 |