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::icon::Icon; |
| 9 | use crate::components::toast::{ToastMsg, show}; |
| 10 | use crate::icons::IconName; |
| 11 | use crate::router::share_url; |
| 12 | use crate::util::format_date; |
| 13 | |
| 14 | // --------------------------------------------------------------------------- |
| 15 | // Expiry helpers (compute the RFC 3339 expiry client-side) |
| 16 | // --------------------------------------------------------------------------- |
| 17 | |
| 18 | /// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601). |
| 19 | fn ms_to_rfc3339(ms: f64) -> Option<String> { |
| 20 | if ms.is_nan() { |
| 21 | return None; |
| 22 | } |
| 23 | Some( |
| 24 | js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms)) |
| 25 | .to_iso_string() |
| 26 | .into(), |
| 27 | ) |
| 28 | } |
| 29 | |
| 30 | /// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for |
| 31 | /// "never". Presets are computed from "now"; "custom" parses the |
| 32 | /// datetime-local value as the browser's local time. |
| 33 | fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> { |
| 34 | let now = js_sys::Date::now(); |
| 35 | let h = 60.0 * 60.0 * 1000.0; |
| 36 | let target_ms: Option<f64> = match choice { |
| 37 | "never" => return None, |
| 38 | "1h" => Some(now + h), |
| 39 | "1d" => Some(now + 24.0 * h), |
| 40 | "1w" => Some(now + 7.0 * 24.0 * h), |
| 41 | "1mo" => Some(now + 30.0 * 24.0 * h), |
| 42 | "custom" => { |
| 43 | if custom.is_empty() { |
| 44 | None |
| 45 | } else { |
| 46 | // `Date.parse` treats a bare datetime as local time, so the |
| 47 | // result is already the correct UTC instant. |
| 48 | Some(js_sys::Date::parse(custom)) |
| 49 | } |
| 50 | } |
| 51 | _ => return None, |
| 52 | }; |
| 53 | target_ms.and_then(ms_to_rfc3339) |
| 54 | } |
| 55 | |
| 56 | fn copy_to_clipboard(text: &str, toast: ToastMsg) { |
| 57 | let Some(win) = web_sys::window() else { |
| 58 | show(toast, "Copy the link manually".to_string()); |
| 59 | return; |
| 60 | }; |
| 61 | let cb = win.navigator().clipboard(); |
| 62 | let t = text.to_string(); |
| 63 | spawn_local(async move { |
| 64 | let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await; |
| 65 | show(toast, "Link copied to clipboard".to_string()); |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | fn select_value(ev: &web_sys::Event) -> Option<String> { |
| 70 | ev.target() |
| 71 | .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok()) |
| 72 | .map(|s| s.value()) |
| 73 | } |
| 74 | |
| 75 | // --------------------------------------------------------------------------- |
| 76 | // Share creation dialog (two-phase: form -> link) |
| 77 | // --------------------------------------------------------------------------- |
| 78 | |
| 79 | #[component] |
| 80 | pub fn ShareDialog( |
| 81 | name: String, |
| 82 | root_id: i64, |
| 83 | path: String, |
| 84 | allow_writable: bool, |
| 85 | root_writable: bool, |
| 86 | close: Callback<()>, |
| 87 | ) -> impl IntoView { |
| 88 | let toast = use_context::<ToastMsg>().expect("toast context"); |
| 89 | let (writable, set_writable) = signal(false); |
| 90 | let (choice, set_choice) = signal("never".to_string()); |
| 91 | let (custom, set_custom) = signal(String::new()); |
| 92 | let (created, set_created) = signal(Option::<ShareInfo>::None); |
| 93 | let (busy, set_busy) = signal(false); |
| 94 | |
| 95 | let do_create = move |_| { |
| 96 | if busy.get() { |
| 97 | return; |
| 98 | } |
| 99 | set_busy.set(true); |
| 100 | let exp = expiry_to_rfc3339(&choice.get(), &custom.get()); |
| 101 | let wr = writable.get(); |
| 102 | let rid = root_id; |
| 103 | let p = path.clone(); |
| 104 | spawn_local(async move { |
| 105 | match api::create_share(rid, &p, wr, exp.as_deref()).await { |
| 106 | Ok(info) => set_created.set(Some(info)), |
| 107 | Err(e) => { |
| 108 | show(toast, format!("Could not create share: {e}")); |
| 109 | set_busy.set(false); |
| 110 | } |
| 111 | } |
| 112 | }); |
| 113 | }; |
| 114 | |
| 115 | view! { |
| 116 | <div class="modal-overlay" on:click=move |_| close.run(())> |
| 117 | <div |
| 118 | class="modal share-modal" |
| 119 | on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation() |
| 120 | > |
| 121 | {move || match created.get() { |
| 122 | Some(ref info) => { |
| 123 | let link = share_url(&info.token); |
| 124 | view! { |
| 125 | <h2 class="modal-title">"Share link"</h2> |
| 126 | <p class="modal-message"> |
| 127 | "Anyone with this link can " |
| 128 | {if writable.get() { "read and write " } else { "read " }} |
| 129 | {format!("“{name}”.")} |
| 130 | </p> |
| 131 | <div class="share-link-row"> |
| 132 | <input |
| 133 | class="share-link-input" |
| 134 | readonly=true |
| 135 | value=link.clone() |
| 136 | on:click=move |ev: web_sys::MouseEvent| { |
| 137 | if let Some(i) = ev |
| 138 | .target() |
| 139 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 140 | { |
| 141 | i.select(); |
| 142 | } |
| 143 | } |
| 144 | /> |
| 145 | <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>"Copy"</button> |
| 146 | </div> |
| 147 | <p class="muted share-expiry-note"> |
| 148 | {match info.expires_at { |
| 149 | Some(ref e) => format!("Expires {0}", format_date(e)), |
| 150 | None => "Never expires".to_string(), |
| 151 | }} |
| 152 | </p> |
| 153 | <div class="modal-actions"> |
| 154 | <button class="btn btn-primary" on:click=move |_| close.run(())>"Done"</button> |
| 155 | </div> |
| 156 | } |
| 157 | .into_view() |
| 158 | .into_any() |
| 159 | } |
| 160 | None => view! { |
| 161 | <h2 class="modal-title">{format!("Share {name}")}</h2> |
| 162 | <p class="modal-message">"Create a link for this item. Writable links let viewers upload and edit."</p> |
| 163 | {move || if allow_writable && root_writable { |
| 164 | view! { |
| 165 | <label class="share-check"> |
| 166 | <input |
| 167 | type="checkbox" |
| 168 | checked=move || writable.get() |
| 169 | on:change=move |ev: web_sys::Event| { |
| 170 | if let Some(t) = ev |
| 171 | .target() |
| 172 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 173 | { |
| 174 | set_writable.set(t.checked()); |
| 175 | } |
| 176 | } |
| 177 | /> |
| 178 | "Allow editing (read-write)" |
| 179 | </label> |
| 180 | } |
| 181 | .into_view() |
| 182 | .into_any() |
| 183 | } else { |
| 184 | view! { |
| 185 | <p class="muted"> |
| 186 | {if root_writable { |
| 187 | "Writable shares are disabled by the server." |
| 188 | .to_string() |
| 189 | } else { |
| 190 | "This folder is read-only, so the share is read-only too." |
| 191 | .to_string() |
| 192 | }} |
| 193 | </p> |
| 194 | } |
| 195 | .into_view() |
| 196 | .into_any() |
| 197 | }} |
| 198 | <div class="share-expiry-row"> |
| 199 | <label for="share-expiry">"Expires"</label> |
| 200 | <select |
| 201 | id="share-expiry" |
| 202 | on:change=move |ev: web_sys::Event| { |
| 203 | if let Some(v) = select_value(&ev) { |
| 204 | set_choice.set(v); |
| 205 | } |
| 206 | } |
| 207 | > |
| 208 | <option value="never">"Never"</option> |
| 209 | <option value="1h">"1 hour"</option> |
| 210 | <option value="1d">"1 day"</option> |
| 211 | <option value="1w">"1 week"</option> |
| 212 | <option value="1mo">"1 month"</option> |
| 213 | <option value="custom">"Custom…"</option> |
| 214 | </select> |
| 215 | </div> |
| 216 | {move || if choice.get() == "custom" { |
| 217 | view! { |
| 218 | <div class="share-expiry-row"> |
| 219 | <label for="share-custom-dt">"Until"</label> |
| 220 | <input |
| 221 | type="datetime-local" |
| 222 | id="share-custom-dt" |
| 223 | value=move || custom.get() |
| 224 | on:change=move |ev: web_sys::Event| { |
| 225 | if let Some(t) = ev |
| 226 | .target() |
| 227 | .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) |
| 228 | { |
| 229 | set_custom.set(t.value()); |
| 230 | } |
| 231 | } |
| 232 | /> |
| 233 | </div> |
| 234 | } |
| 235 | .into_view() |
| 236 | .into_any() |
| 237 | } else { |
| 238 | view! {}.into_any() |
| 239 | }} |
| 240 | <div class="modal-actions"> |
| 241 | <button class="btn" on:click=move |_| close.run(())>"Cancel"</button> |
| 242 | <button |
| 243 | class="btn btn-primary" |
| 244 | disabled=move || busy.get() |
| 245 | on:click=do_create.clone() |
| 246 | > |
| 247 | {move || if busy.get() { "Creating…" } else { "Create link" }} |
| 248 | </button> |
| 249 | </div> |
| 250 | } |
| 251 | .into_view() |
| 252 | .into_any(), |
| 253 | }} |
| 254 | </div> |
| 255 | </div> |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // --------------------------------------------------------------------------- |
| 260 | // "Your shares" manage view |
| 261 | // --------------------------------------------------------------------------- |
| 262 | |
| 263 | #[component] |
| 264 | pub fn SharesView() -> impl IntoView { |
| 265 | let toast = use_context::<ToastMsg>().expect("toast context"); |
| 266 | let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None); |
| 267 | let (confirm_all, set_confirm_all) = signal(false); |
| 268 | let (busy_all, set_busy_all) = signal(false); |
| 269 | { |
| 270 | let set = set_shares; |
| 271 | let toast2 = toast; |
| 272 | spawn_local(async move { |
| 273 | match api::list_shares().await { |
| 274 | Ok(s) => set.set(Some(s)), |
| 275 | Err(e) => show(toast2, e.to_string()), |
| 276 | } |
| 277 | }); |
| 278 | } |
| 279 | |
| 280 | // Delete every share of the current user (no server-side bulk endpoint). |
| 281 | let delete_all = move |_| { |
| 282 | set_confirm_all.set(false); |
| 283 | let Some(list) = shares.get() else { |
| 284 | return; |
| 285 | }; |
| 286 | if list.is_empty() { |
| 287 | return; |
| 288 | } |
| 289 | let ids: Vec<i64> = list.iter().map(|s| s.id).collect(); |
| 290 | set_busy_all.set(true); |
| 291 | let write = set_shares; |
| 292 | let toast2 = toast; |
| 293 | spawn_local(async move { |
| 294 | let mut failed = 0usize; |
| 295 | for id in &ids { |
| 296 | if api::delete_share(*id).await.is_err() { |
| 297 | failed += 1; |
| 298 | } |
| 299 | } |
| 300 | if failed == 0 { |
| 301 | write.set(Some(Vec::new())); |
| 302 | show(toast2, format!("Deleted {} shares", ids.len())); |
| 303 | } else if failed < ids.len() { |
| 304 | // Partial failure: re-read what is actually left. |
| 305 | if let Ok(s) = api::list_shares().await { |
| 306 | write.set(Some(s)); |
| 307 | } |
| 308 | show( |
| 309 | toast2, |
| 310 | format!("{failed} of {} shares could not be deleted", ids.len()), |
| 311 | ); |
| 312 | } else { |
| 313 | show(toast2, "Could not delete shares".to_string()); |
| 314 | } |
| 315 | set_busy_all.set(false); |
| 316 | }); |
| 317 | }; |
| 318 | |
| 319 | view! { |
| 320 | <div class="shares-view"> |
| 321 | <div class="view-header"> |
| 322 | <div class="users-header"> |
| 323 | <h3 class="admin-section-title">"Your shares"</h3> |
| 324 | {move || { |
| 325 | let n = shares.get().map(|s| s.len()).unwrap_or(0); |
| 326 | if n > 0 { |
| 327 | view! { |
| 328 | <button |
| 329 | class="btn btn-sm btn-danger" |
| 330 | disabled=move || busy_all.get() |
| 331 | on:click=move |_| set_confirm_all.set(true) |
| 332 | > |
| 333 | <Icon name=IconName::Delete class="ic-btn".to_string()/> |
| 334 | "Delete all" |
| 335 | </button> |
| 336 | } |
| 337 | .into_view() |
| 338 | .into_any() |
| 339 | } else { |
| 340 | view! {}.into_any() |
| 341 | } |
| 342 | }} |
| 343 | </div> |
| 344 | <p class="muted view-hint">"Links you created. Right-click a file or folder and choose “Share” to add one."</p> |
| 345 | </div> |
| 346 | {move || match shares.get() { |
| 347 | None => view! { |
| 348 | <p class="muted">"Loading…"</p> |
| 349 | } |
| 350 | .into_view() |
| 351 | .into_any(), |
| 352 | Some(ref list) if list.is_empty() => view! { |
| 353 | <div class="empty-state"> |
| 354 | <Icon name=IconName::Share class="empty-glyph".to_string()/> |
| 355 | <h3>"No shares yet"</h3> |
| 356 | <p class="muted">"Right-click a file or folder and choose “Share” to create a link."</p> |
| 357 | </div> |
| 358 | } |
| 359 | .into_view() |
| 360 | .into_any(), |
| 361 | Some(ref list) => { |
| 362 | let items = list |
| 363 | .iter() |
| 364 | .map(|s| { |
| 365 | let s2 = s.clone(); |
| 366 | let link = share_url(&s.token); |
| 367 | let glyph = if s.is_file { |
| 368 | IconName::File |
| 369 | } else { |
| 370 | IconName::Folder |
| 371 | }; |
| 372 | let mut meta = format!( |
| 373 | "{} · {}", |
| 374 | s.target, |
| 375 | if s.writable { "read-write" } else { "read-only" } |
| 376 | ); |
| 377 | if let Some(e) = &s.expires_at { |
| 378 | meta.push_str(&format!(" · expires {}", format_date(e))); |
| 379 | } |
| 380 | meta.push_str(&format!(" · created {}", format_date(&s.created_at))); |
| 381 | view! { |
| 382 | <div class="share-item"> |
| 383 | <div class="share-item-main"> |
| 384 | <div class="share-item-name"> |
| 385 | <Icon name=glyph class="ic-row".to_string()/> |
| 386 | <span>{s.name.clone()}</span> |
| 387 | </div> |
| 388 | <div class="share-item-meta muted">{meta}</div> |
| 389 | </div> |
| 390 | <div class="share-item-actions"> |
| 391 | <button |
| 392 | class="btn btn-sm" |
| 393 | title="Copy link" |
| 394 | on:click=move |_| copy_to_clipboard(&link, toast) |
| 395 | > |
| 396 | <Icon name=IconName::Copy class="ic-btn".to_string()/> |
| 397 | "Copy" |
| 398 | </button> |
| 399 | <button |
| 400 | class="btn btn-sm btn-danger" |
| 401 | title="Delete share" |
| 402 | on:click=move |_| { |
| 403 | let id = s2.id; |
| 404 | let read = shares; |
| 405 | let write = set_shares; |
| 406 | let toast2 = toast; |
| 407 | spawn_local(async move { |
| 408 | if api::delete_share(id).await.is_ok() { |
| 409 | if let Some(mut cur) = read.get() { |
| 410 | cur.retain(|x| x.id != id); |
| 411 | write.set(Some(cur)); |
| 412 | } |
| 413 | show(toast2, "Share deleted"); |
| 414 | } else { |
| 415 | show(toast2, "Could not delete share".to_string()); |
| 416 | } |
| 417 | }); |
| 418 | } |
| 419 | > |
| 420 | <Icon name=IconName::Delete class="ic-btn".to_string()/> |
| 421 | "Delete" |
| 422 | </button> |
| 423 | </div> |
| 424 | </div> |
| 425 | } |
| 426 | }) |
| 427 | .collect::<Vec<_>>(); |
| 428 | view! { |
| 429 | <div class="share-list">{items}</div> |
| 430 | } |
| 431 | .into_view() |
| 432 | .into_any() |
| 433 | } |
| 434 | }} |
| 435 | |
| 436 | // Delete-all confirmation. |
| 437 | {move || { |
| 438 | if confirm_all.get() { |
| 439 | let n = shares.get().map(|s| s.len()).unwrap_or(0); |
| 440 | view! { |
| 441 | <div class="modal-overlay" on:click=move |_| set_confirm_all.set(false)> |
| 442 | <div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()> |
| 443 | <h2 class="modal-title">"Delete all shares?"</h2> |
| 444 | <p class="modal-message"> |
| 445 | {format!("Delete all {n} share links? Anyone holding them loses access. This cannot be undone.")} |
| 446 | </p> |
| 447 | <div class="modal-actions"> |
| 448 | <button class="btn" on:click=move |_| set_confirm_all.set(false)> |
| 449 | "Cancel" |
| 450 | </button> |
| 451 | <button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all> |
| 452 | "Delete all" |
| 453 | </button> |
| 454 | </div> |
| 455 | </div> |
| 456 | </div> |
| 457 | } |
| 458 | .into_view() |
| 459 | .into_any() |
| 460 | } else { |
| 461 | view! {}.into_any() |
| 462 | } |
| 463 | }} |
| 464 | </div> |
| 465 | } |
| 466 | } |
| 467 |