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::modal::Modal;
10use crate::components::toast::{ToastMsg, show, show_error};
11use crate::i18n;
12use crate::icons::IconName;
13use crate::router::{dav_share_url, share_url};
14use crate::util::format_date;
15use crate::util::{select_input, select_value};
16
17// ---------------------------------------------------------------------------
18// Expiry helpers (compute the RFC 3339 expiry client-side)
19// ---------------------------------------------------------------------------
20
21/// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601).
22fn ms_to_rfc3339(ms: f64) -> Option<String> {
23 if ms.is_nan() {
24 return None;
25 }
26 Some(
27 js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
28 .to_iso_string()
29 .into(),
30 )
31}
32
33/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
34/// "never". Presets are computed from "now"; "custom" parses the
35/// datetime-local value as the browser's local time.
36fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
37 let now = js_sys::Date::now();
38 let h = 60.0 * 60.0 * 1000.0;
39 let target_ms: Option<f64> = match choice {
40 "never" => return None,
41 "1h" => Some(now + h),
42 "1d" => Some(now + 24.0 * h),
43 "1w" => Some(now + 7.0 * 24.0 * h),
44 "1mo" => Some(now + 30.0 * 24.0 * h),
45 "custom" => {
46 if custom.is_empty() {
47 None
48 } else {
49 // `Date.parse` treats a bare datetime as local time, so the
50 // result is already the correct UTC instant.
51 Some(js_sys::Date::parse(custom))
52 }
53 }
54 _ => return None,
55 };
56 target_ms.and_then(ms_to_rfc3339)
57}
58
59fn copy_to_clipboard(text: &str, toast: ToastMsg) {
60 let Some(win) = web_sys::window() else {
61 show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string());
62 return;
63 };
64 let cb = win.navigator().clipboard();
65 let t = text.to_string();
66 spawn_local(async move {
67 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
68 show(toast, i18n::t(i18n::k::LINK_COPIED).to_string());
69 });
70}
71
72// ---------------------------------------------------------------------------
73// Share creation dialog (two-phase: form -> link)
74// ---------------------------------------------------------------------------
75
76#[component]
77pub fn ShareDialog(
78 name: String,
79 root_id: i64,
80 path: String,
81 allow_writable: bool,
82 root_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 (password, set_password) = signal(String::new());
90 let (created, set_created) = signal(Option::<ShareInfo>::None);
91 let (busy, set_busy) = signal(false);
92 let name_msg = name.clone();
93
94 let do_create = move |_| {
95 if busy.get() {
96 return;
97 }
98 set_busy.set(true);
99 let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
100 let wr = writable.get();
101 let rid = root_id;
102 let p = path.clone();
103 // An empty field means "no password", not a password of "".
104 let pw = password.get().trim().to_string();
105 let pw = (!pw.is_empty()).then_some(pw);
106 spawn_local(async move {
107 match api::create_share(rid, &p, wr, exp.as_deref(), pw.as_deref()).await {
108 Ok(info) => set_created.set(Some(info)),
109 Err(e) => {
110 show(
111 toast,
112 i18n::t_fmt(i18n::k::SHARE_CREATE_ERR, &e.to_string()),
113 );
114 set_busy.set(false);
115 }
116 }
117 });
118 };
119
120 view! {
121 <Modal class="share-modal" on_close=close>
122 {move || match created.get() {
123 Some(ref info) => {
124 let link = share_url(&info.token);
125 let dav_link = dav_share_url(&info.token);
126 let is_file = info.is_file;
127 view! {
128 <h2 class="modal-title">{i18n::tr(i18n::k::SHARE_LINK)}</h2>
129 <p class="modal-message">
130 {if writable.get() {
131 i18n::t_fmt(i18n::k::SHARE_PERM_RW, &name_msg)
132 } else {
133 i18n::t_fmt(i18n::k::SHARE_PERM_RO, &name_msg)
134 }}
135 </p>
136 <div class="share-link-row">
137 <input
138 class="share-link-input"
139 readonly=true
140 value=link.clone()
141 on:click=|ev| select_input(&ev)
142 />
143 <button
144 class="btn"
145 title={i18n::tr(i18n::k::COPY_LINK)}
146 on:click=move |_| copy_to_clipboard(&link, toast)
147 >
148 <Icon name=IconName::Copy class="ic-btn".to_string()/>
149 {i18n::t(i18n::k::COPY)}
150 </button>
151 </div>
152 // Folder shares only: a file share has no
153 // collection to mount.
154 {(!is_file).then(|| view! {
155 <div class="share-link-row">
156 <input
157 class="share-link-input"
158 readonly=true
159 value=dav_link.clone()
160 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
161 on:click=|ev| select_input(&ev)
162 />
163 <button
164 class="btn"
165 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
166 on:click=move |_| copy_to_clipboard(&dav_link, toast)
167 >
168 <Icon name=IconName::Copy class="ic-btn".to_string()/>
169 "WebDAV"
170 </button>
171 </div>
172 })}
173 <p class="muted share-expiry-note">
174 {match info.expires_at {
175 Some(ref e) => i18n::t_fmt(i18n::k::EXPIRES_AT, &format_date(e)),
176 None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
177 }}
178 </p>
179 {info.has_password.then(|| view! {
180 <p class="muted share-expiry-note">
181 {i18n::tr(i18n::k::SHARE_PROTECTED)}
182 </p>
183 })}
184 <div class="modal-actions">
185 <button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
186 </div>
187 }
188 .into_view()
189 .into_any()
190 }
191 None => view! {
192 <h2 class="modal-title">{i18n::t_fmt(i18n::k::SHARE_TITLE, &name)}</h2>
193 <p class="modal-message">{i18n::tr(i18n::k::SHARE_DIALOG_HINT)}</p>
194 {move || if allow_writable && root_writable {
195 view! {
196 <label class="share-check">
197 <input
198 type="checkbox"
199 checked=move || writable.get()
200 on:change=move |ev: web_sys::Event| {
201 if let Some(t) = ev
202 .target()
203 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
204 {
205 set_writable.set(t.checked());
206 }
207 }
208 />
209 {i18n::tr(i18n::k::ALLOW_EDITING)}
210 </label>
211 }
212 .into_view()
213 .into_any()
214 } else {
215 view! {
216 <p class="muted">
217 {move || {
218 if root_writable {
219 i18n::t(i18n::k::RW_DISABLED).to_string()
220 } else {
221 i18n::t(i18n::k::RW_RO_FOLDER).to_string()
222 }
223 }}
224 </p>
225 }
226 .into_view()
227 .into_any()
228 }}
229 <div class="share-expiry-row">
230 <label for="share-expiry">{i18n::tr(i18n::k::EXPIRES)}</label>
231 <select
232 id="share-expiry"
233 on:change=move |ev: web_sys::Event| {
234 if let Some(v) = select_value(&ev) {
235 set_choice.set(v);
236 }
237 }
238 >
239 <option value="never">{i18n::tr(i18n::k::NEVER)}</option>
240 <option value="1h">{i18n::t(i18n::k::EXPIRY_1H)}</option>
241 <option value="1d">{i18n::t(i18n::k::EXPIRY_1D)}</option>
242 <option value="1w">{i18n::t(i18n::k::EXPIRY_1W)}</option>
243 <option value="1mo">{i18n::t(i18n::k::EXPIRY_1MO)}</option>
244 <option value="custom">{i18n::tr(i18n::k::CUSTOM)}</option>
245 </select>
246 </div>
247 {move || if choice.get() == "custom" {
248 view! {
249 <div class="share-expiry-row">
250 <label for="share-custom-dt">{i18n::tr(i18n::k::UNTIL)}</label>
251 <input
252 type="datetime-local"
253 id="share-custom-dt"
254 value=move || custom.get()
255 on:change=move |ev: web_sys::Event| {
256 if let Some(t) = ev
257 .target()
258 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
259 {
260 set_custom.set(t.value());
261 }
262 }
263 />
264 </div>
265 }
266 .into_view()
267 .into_any()
268 } else {
269 view! {}.into_any()
270 }}
271 <div class="share-pw-row">
272 <label for="share-pw">{i18n::tr(i18n::k::PASSWORD)}</label>
273 <input
274 type="password"
275 id="share-pw"
276 autocomplete="new-password"
277 value=move || password.get()
278 on:input=move |ev: web_sys::Event| {
279 if let Some(t) = ev
280 .target()
281 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
282 {
283 set_password.set(t.value());
284 }
285 }
286 />
287 </div>
288 <p class="muted share-pw-hint">{i18n::tr(i18n::k::SHARE_PASSWORD_HINT)}</p>
289 <div class="modal-actions">
290 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
291 <button
292 class="btn btn-primary"
293 disabled=move || busy.get()
294 on:click=do_create.clone()
295 >
296 {move || {
297 if busy.get() {
298 i18n::t(i18n::k::CREATING).to_string()
299 } else {
300 i18n::t(i18n::k::CREATE_LINK).to_string()
301 }
302 }}
303 </button>
304 </div>
305 }
306 .into_view()
307 .into_any(),
308 }}
309 </Modal>
310 }
311}
312
313// ---------------------------------------------------------------------------
314// "Your shares" manage view
315// ---------------------------------------------------------------------------
316
317#[component]
318pub fn SharesView() -> impl IntoView {
319 let toast = use_context::<ToastMsg>().expect("toast context");
320 let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
321 // Set when the list could not be loaded; the error card offers a retry.
322 let (load_err, set_load_err) = signal(Option::<String>::None);
323 let (confirm_all, set_confirm_all) = signal(false);
324 let (busy_all, set_busy_all) = signal(false);
325 let load = move || {
326 set_load_err.set(None);
327 spawn_local(async move {
328 match api::list_shares().await {
329 Ok(s) => set_shares.set(Some(s)),
330 Err(e) => set_load_err.set(Some(e.to_string())),
331 }
332 });
333 };
334 load();
335
336 // Delete every share of the current user (no server-side bulk endpoint).
337 let delete_all = move |_| {
338 set_confirm_all.set(false);
339 let Some(list) = shares.get() else {
340 return;
341 };
342 if list.is_empty() {
343 return;
344 }
345 let ids: Vec<i64> = list.iter().map(|s| s.id).collect();
346 set_busy_all.set(true);
347 let write = set_shares;
348 let toast2 = toast;
349 spawn_local(async move {
350 let mut failed = 0usize;
351 for id in &ids {
352 if api::delete_share(*id).await.is_err() {
353 failed += 1;
354 }
355 }
356 if failed == 0 {
357 write.set(Some(Vec::new()));
358 show(
359 toast2,
360 i18n::t(i18n::k::DELETED_N_SHARES)
361 .to_string()
362 .replace("{}", &ids.len().to_string()),
363 );
364 } else if failed < ids.len() {
365 // Partial failure: re-read what is actually left.
366 if let Ok(s) = api::list_shares().await {
367 write.set(Some(s));
368 }
369 show(
370 toast2,
371 i18n::t(i18n::k::DELETE_PARTIAL)
372 .replace("{}", &failed.to_string())
373 .replace("{}", &ids.len().to_string()),
374 );
375 } else {
376 show_error(toast2, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
377 }
378 set_busy_all.set(false);
379 });
380 };
381
382 view! {
383 <div class="shares-view">
384 <div class="view-header">
385 <div class="users-header">
386 <h3 class="admin-section-title">{i18n::tr(i18n::k::YOUR_SHARES)}</h3>
387 {move || {
388 let n = shares.get().map(|s| s.len()).unwrap_or(0);
389 if n > 0 {
390 view! {
391 <button
392 class="btn btn-sm btn-danger"
393 disabled=move || busy_all.get()
394 on:click=move |_| set_confirm_all.set(true)
395 >
396 <Icon name=IconName::Delete class="ic-btn".to_string()/>
397 {i18n::tr(i18n::k::DELETE_ALL)}
398 </button>
399 }
400 .into_view()
401 .into_any()
402 } else {
403 view! {}.into_any()
404 }
405 }}
406 </div>
407 <p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
408 </div>
409 {move || match shares.get() {
410 None => match load_err.get() {
411 Some(msg) => view! {
412 <div class="card error-card">
413 <span>{msg}</span>
414 <button class="btn" on:click=move |_| load()>
415 {i18n::tr(i18n::k::RETRY)}
416 </button>
417 </div>
418 }
419 .into_view()
420 .into_any(),
421 None => view! {
422 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
423 }
424 .into_view()
425 .into_any(),
426 },
427 Some(ref list) if list.is_empty() => view! {
428 <div class="empty-state">
429 <Icon name=IconName::Share class="empty-glyph".to_string()/>
430 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
431 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
432 </div>
433 }
434 .into_view()
435 .into_any(),
436 Some(ref list) => {
437 let items = list
438 .iter()
439 .map(|s| {
440 let s2 = s.clone();
441 let link = share_url(&s.token);
442 let dav_link = dav_share_url(&s.token);
443 let is_file = s.is_file;
444 let glyph = if s.is_file {
445 IconName::File
446 } else {
447 IconName::Folder
448 };
449 let mut meta = format!(
450 "{} · {}",
451 s.target,
452 if s.writable {
453 i18n::t(i18n::k::MODE_RW)
454 } else {
455 i18n::t(i18n::k::MODE_RO)
456 }
457 );
458 if let Some(e) = &s.expires_at {
459 meta.push_str(&format!(
460 " · {}",
461 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
462 ));
463 }
464 meta.push_str(&format!(
465 " · {}",
466 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
467 ));
468 if s.has_password {
469 meta.push_str(&format!(" · {}", i18n::t(i18n::k::SHARE_PROTECTED)));
470 }
471 view! {
472 <div class="share-item">
473 <div class="share-item-main">
474 <div class="share-item-name">
475 <Icon name=glyph class="ic-row".to_string()/>
476 <span>{s.name.clone()}</span>
477 </div>
478 <div class="share-item-meta muted">{meta}</div>
479 </div>
480 <div class="share-item-actions">
481 <button
482 class="btn btn-sm"
483 title={i18n::tr(i18n::k::COPY_LINK)}
484 on:click=move |_| copy_to_clipboard(&link, toast)
485 >
486 <Icon name=IconName::Copy class="ic-btn".to_string()/>
487 {i18n::tr(i18n::k::COPY)}
488 </button>
489 // Folder shares only: a file share has
490 // no collection to mount.
491 <Show when=move || !is_file>
492 <button
493 class="btn btn-sm"
494 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
495 on:click={
496 let dav = dav_link.clone();
497 move |_| copy_to_clipboard(&dav, toast)
498 }
499 >
500 <Icon name=IconName::Copy class="ic-btn".to_string()/>
501 "WebDAV"
502 </button>
503 </Show>
504 <button
505 class="btn btn-sm btn-danger"
506 title={i18n::tr(i18n::k::DELETE_SHARE)}
507 on:click=move |_| {
508 let id = s2.id;
509 let read = shares;
510 let write = set_shares;
511 let toast2 = toast;
512 spawn_local(async move {
513 if api::delete_share(id).await.is_ok() {
514 if let Some(mut cur) = read.get() {
515 cur.retain(|x| x.id != id);
516 write.set(Some(cur));
517 }
518 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
519 } else {
520 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
521 }
522 });
523 }
524 >
525 <Icon name=IconName::Delete class="ic-btn".to_string()/>
526 {i18n::tr(i18n::k::DELETE)}
527 </button>
528 </div>
529 </div>
530 }
531 })
532 .collect::<Vec<_>>();
533 view! {
534 <div class="share-list">{items}</div>
535 }
536 .into_view()
537 .into_any()
538 }
539 }}
540
541 // Delete-all confirmation.
542 {move || {
543 if confirm_all.get() {
544 let n = shares.get().map(|s| s.len()).unwrap_or(0);
545 view! {
546 <Modal
547 class="card"
548 on_close=Callback::new(move |_| set_confirm_all.set(false))
549 >
550 <h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
551 <p class="modal-message">
552 {i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &n.to_string())}
553 </p>
554 <div class="modal-actions">
555 <button class="btn" on:click=move |_| set_confirm_all.set(false)>
556 {i18n::tr(i18n::k::CANCEL)}
557 </button>
558 <button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all>
559 {i18n::tr(i18n::k::DELETE_ALL)}
560 </button>
561 </div>
562 </Modal>
563 }
564 .into_view()
565 .into_any()
566 } else {
567 view! {}.into_any()
568 }
569 }}
570 </div>
571 }
572}
573