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