shares.rs
⎇
Raw
1//! Share creation dialog and the "Your shares" management panel (milestone 6).
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen_futures::spawn_local;
6
7use crate::api::{self, ShareInfo};
8use crate::components::icon::Icon;
9use crate::components::toast::{ToastMsg, show};
10use crate::icons::IconName;
11use crate::router::share_url;
12use 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).
19fn 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.
33fn 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
56fn 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
69fn 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]
80pub 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]
264pub 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