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 std::collections::{BTreeMap, HashMap};
8use std::sync::Arc;
9
10use crate::api::{self, AdminShare, Me, ShareInfo, UserInfo};
11use crate::components::icon::icon_svg;
12use crate::components::modal::Modal;
13use crate::components::toast::{ToastMsg, show, show_error};
14use crate::i18n;
15use crate::icons::IconName;
16use crate::router::{dav_share_url, share_url};
17use crate::util::format_date;
18use crate::util::{select_input, select_value};
19
20// ---------------------------------------------------------------------------
21// Expiry helpers (compute the RFC 3339 expiry client-side)
22// ---------------------------------------------------------------------------
23
24/// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601).
25fn ms_to_rfc3339(ms: f64) -> Option<String> {
26 if ms.is_nan() {
27 return None;
28 }
29 Some(
30 js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
31 .to_iso_string()
32 .into(),
33 )
34}
35
36/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
37/// "never". Presets are computed from "now"; "custom" parses the
38/// datetime-local value as the browser's local time.
39fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
40 let now = js_sys::Date::now();
41 let h = 60.0 * 60.0 * 1000.0;
42 let target_ms: Option<f64> = match choice {
43 "never" => return None,
44 "1h" => Some(now + h),
45 "1d" => Some(now + 24.0 * h),
46 "1w" => Some(now + 7.0 * 24.0 * h),
47 "1mo" => Some(now + 30.0 * 24.0 * h),
48 "custom" => {
49 if custom.is_empty() {
50 None
51 } else {
52 // `Date.parse` treats a bare datetime as local time, so the
53 // result is already the correct UTC instant.
54 Some(js_sys::Date::parse(custom))
55 }
56 }
57 _ => return None,
58 };
59 target_ms.and_then(ms_to_rfc3339)
60}
61
62fn copy_to_clipboard(text: &str, toast: ToastMsg) {
63 let Some(win) = web_sys::window() else {
64 show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string());
65 return;
66 };
67 let cb = win.navigator().clipboard();
68 let t = text.to_string();
69 spawn_local(async move {
70 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
71 show(toast, i18n::t(i18n::k::LINK_COPIED).to_string());
72 });
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 (password, set_password) = signal(String::new());
93 let (created, set_created) = signal(Option::<ShareInfo>::None);
94 let (busy, set_busy) = signal(false);
95 let name_msg = name.clone();
96
97 let do_create = move |_| {
98 if busy.get() {
99 return;
100 }
101 set_busy.set(true);
102 let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
103 let wr = writable.get();
104 let rid = root_id;
105 let p = path.clone();
106 // An empty field means "no password", not a password of "".
107 let pw = password.get().trim().to_string();
108 let pw = (!pw.is_empty()).then_some(pw);
109 spawn_local(async move {
110 match api::create_share(rid, &p, wr, exp.as_deref(), pw.as_deref()).await {
111 Ok(info) => set_created.set(Some(info)),
112 Err(e) => {
113 show(
114 toast,
115 i18n::t_fmt(i18n::k::SHARE_CREATE_ERR, &e.to_string()),
116 );
117 set_busy.set(false);
118 }
119 }
120 });
121 };
122
123 view! {
124 <Modal class="share-modal" on_close=close>
125 {move || match created.get() {
126 Some(ref info) => {
127 let link = share_url(&info.token);
128 let dav_link = dav_share_url(&info.token);
129 let is_file = info.is_file;
130 view! {
131 <h2 class="modal-title">{i18n::tr(i18n::k::SHARE_LINK)}</h2>
132 <p class="modal-message">
133 {if writable.get() {
134 i18n::t_fmt(i18n::k::SHARE_PERM_RW, &name_msg)
135 } else {
136 i18n::t_fmt(i18n::k::SHARE_PERM_RO, &name_msg)
137 }}
138 </p>
139 <div class="share-link-row">
140 <input
141 class="share-link-input"
142 readonly=true
143 value=link.clone()
144 on:click=|ev| select_input(&ev)
145 />
146 <button
147 class="btn"
148 title={i18n::tr(i18n::k::COPY_LINK)}
149 on:click=move |_| copy_to_clipboard(&link, toast)
150 >
151 {icon_svg(IconName::Copy, "ic-btn")}
152 {i18n::t(i18n::k::COPY)}
153 </button>
154 </div>
155 // Folder shares only: a file share has no
156 // collection to mount.
157 {(!is_file).then(|| view! {
158 <div class="share-link-row">
159 <input
160 class="share-link-input"
161 readonly=true
162 value=dav_link.clone()
163 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
164 on:click=|ev| select_input(&ev)
165 />
166 <button
167 class="btn"
168 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
169 on:click=move |_| copy_to_clipboard(&dav_link, toast)
170 >
171 {icon_svg(IconName::Copy, "ic-btn")}
172 "WebDAV"
173 </button>
174 </div>
175 })}
176 <p class="muted share-expiry-note">
177 {match info.expires_at {
178 Some(ref e) => i18n::t_fmt(i18n::k::EXPIRES_AT, &format_date(e)),
179 None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
180 }}
181 </p>
182 {info.has_password.then(|| view! {
183 <p class="muted share-expiry-note">
184 {i18n::tr(i18n::k::SHARE_PROTECTED)}
185 </p>
186 })}
187 <div class="modal-actions">
188 <button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
189 </div>
190 }
191 .into_view()
192 .into_any()
193 }
194 None => view! {
195 <h2 class="modal-title">{i18n::t_fmt(i18n::k::SHARE_TITLE, &name)}</h2>
196 <p class="modal-message">{i18n::tr(i18n::k::SHARE_DIALOG_HINT)}</p>
197 {move || if allow_writable && root_writable {
198 view! {
199 <label class="share-check">
200 <input
201 type="checkbox"
202 checked=move || writable.get()
203 on:change=move |ev: web_sys::Event| {
204 if let Some(t) = ev
205 .target()
206 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
207 {
208 set_writable.set(t.checked());
209 }
210 }
211 />
212 {i18n::tr(i18n::k::ALLOW_EDITING)}
213 </label>
214 }
215 .into_view()
216 .into_any()
217 } else {
218 view! {
219 <p class="muted">
220 {move || {
221 if root_writable {
222 i18n::t(i18n::k::RW_DISABLED).to_string()
223 } else {
224 i18n::t(i18n::k::RW_RO_FOLDER).to_string()
225 }
226 }}
227 </p>
228 }
229 .into_view()
230 .into_any()
231 }}
232 <div class="share-expiry-row">
233 <label for="share-expiry">{i18n::tr(i18n::k::EXPIRES)}</label>
234 <select
235 id="share-expiry"
236 on:change=move |ev: web_sys::Event| {
237 if let Some(v) = select_value(&ev) {
238 set_choice.set(v);
239 }
240 }
241 >
242 <option value="never">{i18n::tr(i18n::k::NEVER)}</option>
243 <option value="1h">{i18n::t(i18n::k::EXPIRY_1H)}</option>
244 <option value="1d">{i18n::t(i18n::k::EXPIRY_1D)}</option>
245 <option value="1w">{i18n::t(i18n::k::EXPIRY_1W)}</option>
246 <option value="1mo">{i18n::t(i18n::k::EXPIRY_1MO)}</option>
247 <option value="custom">{i18n::tr(i18n::k::CUSTOM)}</option>
248 </select>
249 </div>
250 {move || if choice.get() == "custom" {
251 view! {
252 <div class="share-expiry-row">
253 <label for="share-custom-dt">{i18n::tr(i18n::k::UNTIL)}</label>
254 <input
255 type="datetime-local"
256 id="share-custom-dt"
257 value=move || custom.get()
258 on:change=move |ev: web_sys::Event| {
259 if let Some(t) = ev
260 .target()
261 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
262 {
263 set_custom.set(t.value());
264 }
265 }
266 />
267 </div>
268 }
269 .into_view()
270 .into_any()
271 } else {
272 view! {}.into_any()
273 }}
274 <div class="share-pw-row">
275 <label for="share-pw">{i18n::tr(i18n::k::PASSWORD)}</label>
276 <input
277 type="password"
278 id="share-pw"
279 autocomplete="new-password"
280 value=move || password.get()
281 on:input=move |ev: web_sys::Event| {
282 if let Some(t) = ev
283 .target()
284 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
285 {
286 set_password.set(t.value());
287 }
288 }
289 />
290 </div>
291 <p class="muted share-pw-hint">{i18n::tr(i18n::k::SHARE_PASSWORD_HINT)}</p>
292 <div class="modal-actions">
293 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
294 <button
295 class="btn btn-primary"
296 disabled=move || busy.get()
297 on:click=do_create.clone()
298 >
299 {move || {
300 if busy.get() {
301 i18n::t(i18n::k::CREATING).to_string()
302 } else {
303 i18n::t(i18n::k::CREATE_LINK).to_string()
304 }
305 }}
306 </button>
307 </div>
308 }
309 .into_view()
310 .into_any(),
311 }}
312 </Modal>
313 }
314}
315
316// ---------------------------------------------------------------------------
317// Share management view
318//
319// A plain user sees their own links. An admin sees every link on the server,
320// one collapsible section per account. A share outlives the account that made
321// it (README "Shares"), so without this view nobody could end those links.
322// ---------------------------------------------------------------------------
323
324// Both endpoints load into `AdminShare`, so grouping and rows never branch on
325// which one answered. The own list has no creator, so the viewer fills it in.
326
327/// One collapsible section: an account and its links.
328struct Group {
329 id: i64,
330 name: String,
331 active: bool,
332 /// The viewer's own links: rendered first, and open by default.
333 mine: bool,
334 shares: Vec<ShareInfo>,
335}
336
337/// Bucket rows by creator. The map key sorts the sections: the viewer's own
338/// first, the rest by account name. Non-admins only ever get their own group.
339/// Rows are pushed in the order the server sent them, so newest stays first.
340fn group_rows(rows: &[AdminShare], my_id: i64) -> Vec<Group> {
341 let mut groups: BTreeMap<(bool, String, i64), Group> = BTreeMap::new();
342 for r in rows {
343 let mine = r.creator_id == my_id;
344 groups
345 .entry((!mine, r.creator_name.to_lowercase(), r.creator_id))
346 .or_insert_with(|| Group {
347 id: r.creator_id,
348 name: r.creator_name.clone(),
349 active: r.creator_active,
350 mine,
351 shares: Vec::new(),
352 })
353 .shares
354 .push(r.share.clone());
355 }
356 groups.into_values().collect()
357}
358
359/// A bulk deletion that is waiting for the answer to the confirmation dialog.
360#[derive(Clone, PartialEq)]
361struct Pending {
362 ids: Vec<i64>,
363 /// The account the question names; `None` asks about everything listed.
364 who: Option<String>,
365}
366
367/// End one link. Only the admin route can end a link the viewer does not own,
368/// so an admin always takes it and everyone else always takes the user route.
369async fn end_share(is_admin: bool, id: i64) -> bool {
370 if is_admin {
371 api::admin_delete_share(id).await
372 } else {
373 api::delete_share(id).await
374 }
375 .is_ok()
376}
377
378/// One share row: name, meta line, copy buttons, delete. Every section renders
379/// its rows through this, so an admin sees exactly what the owner sees.
380fn share_row(s: &ShareInfo, toast: ToastMsg, del: Callback<i64>) -> AnyView {
381 let link = share_url(&s.token);
382 let dav_link = dav_share_url(&s.token);
383 let is_file = s.is_file;
384 let glyph = if is_file {
385 IconName::File
386 } else {
387 IconName::Folder
388 };
389 let mut meta = format!(
390 "{} · {}",
391 s.target,
392 if s.writable {
393 i18n::t(i18n::k::MODE_RW)
394 } else {
395 i18n::t(i18n::k::MODE_RO)
396 }
397 );
398 if let Some(e) = &s.expires_at {
399 meta.push_str(&format!(
400 " · {}",
401 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
402 ));
403 }
404 meta.push_str(&format!(
405 " · {}",
406 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
407 ));
408 if s.has_password {
409 meta.push_str(&format!(" · {}", i18n::t(i18n::k::SHARE_PROTECTED)));
410 }
411 let name = s.name.clone();
412 let id = s.id;
413 view! {
414 <div class="share-item">
415 <div class="share-item-main">
416 <div class="share-item-name">
417 {icon_svg(glyph, "ic-row")}
418 <span>{name}</span>
419 </div>
420 <div class="share-item-meta muted">{meta}</div>
421 </div>
422 <div class="share-item-actions">
423 <button
424 class="btn btn-sm"
425 title={i18n::tr(i18n::k::COPY_LINK)}
426 on:click=move |_| copy_to_clipboard(&link, toast)
427 >
428 {icon_svg(IconName::Copy, "ic-btn")}
429 {i18n::t(i18n::k::COPY)}
430 </button>
431 // Folder shares only: a file share has no collection to mount.
432 <Show when=move || !is_file>
433 <button
434 class="btn btn-sm"
435 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
436 on:click={
437 let dav = dav_link.clone();
438 move |_| copy_to_clipboard(&dav, toast)
439 }
440 >
441 {icon_svg(IconName::Copy, "ic-btn")}
442 "WebDAV"
443 </button>
444 </Show>
445 <button
446 class="btn btn-sm btn-danger"
447 title={i18n::tr(i18n::k::DELETE_SHARE)}
448 on:click=move |_| del.run(id)
449 >
450 {icon_svg(IconName::Delete, "ic-btn")}
451 {i18n::t(i18n::k::DELETE)}
452 </button>
453 </div>
454 </div>
455 }
456 .into_view()
457 .into_any()
458}
459
460#[component]
461pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
462 let toast = use_context::<ToastMsg>().expect("toast context");
463 // One extraction of the signed-in user; everything below reads a field.
464 // The shell renders this view only behind a signed-in `me`.
465 let user: Memo<UserInfo> = Memo::new(move |_| {
466 me.with(|m| m.as_ref().and_then(|m| m.user.clone()))
467 .expect("SharesView needs a signed-in user")
468 });
469 let admin = move || user.with(|u| u.is_admin);
470 let my_id = move || user.with(|u| u.id);
471 // None until the first load answers, so "loading" and "no shares" stay
472 // distinguishable.
473 let (rows, set_rows) = signal(Option::<Vec<AdminShare>>::None);
474 // Set when the list could not be loaded; the error card offers a retry.
475 let (load_err, set_load_err) = signal(Option::<String>::None);
476 let (confirm, set_confirm) = signal(None::<Pending>);
477 let (busy, set_busy) = signal(false);
478 // Which sections are open, by account. Kept out of the DOM: the listing is
479 // rebuilt on every delete, and a section must not snap shut under the user.
480 let (open, set_open) = signal(HashMap::<i64, bool>::new());
481
482 let load = move || {
483 set_load_err.set(None);
484 // `try_get`, not `get`: a bulk delete calls this after its awaits, when
485 // the view may be gone. Reading a disposed memo panics and kills the app.
486 let Some(u) = user.try_get() else {
487 return;
488 };
489 // The own list does not name a creator, so the viewer's own id and name
490 // fill that in. `me` is loaded before this view renders.
491 let is_admin = u.is_admin;
492 let own_id = u.id;
493 let own_name = u.name;
494 spawn_local(async move {
495 let got = if is_admin {
496 api::list_all_shares().await
497 } else {
498 api::list_shares().await.map(|own| {
499 own.into_iter()
500 .map(|share| AdminShare {
501 share,
502 creator_id: own_id,
503 creator_name: own_name.clone(),
504 creator_active: true,
505 })
506 .collect()
507 })
508 };
509 match got {
510 Ok(rs) => set_rows.set(Some(rs)),
511 Err(e) => set_load_err.set(Some(e.to_string())),
512 }
513 });
514 };
515 load();
516
517 let delete_one = Callback::new(move |id: i64| {
518 let is_admin = admin();
519 let toast2 = toast;
520 spawn_local(async move {
521 if end_share(is_admin, id).await {
522 set_rows.update(|rs| {
523 if let Some(list) = rs {
524 list.retain(|r| r.share.id != id);
525 }
526 });
527 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
528 } else {
529 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
530 }
531 });
532 });
533
534 // Bulk delete: no server-side bulk endpoint, so this walks the ids one by one.
535 let delete_all = move |_| {
536 let Some(p) = confirm.get() else { return };
537 set_confirm.set(None);
538 if p.ids.is_empty() || busy.get() {
539 return;
540 }
541 set_busy.set(true);
542 let total = p.ids.len();
543 let is_admin = admin();
544 spawn_local(async move {
545 let mut failed = 0usize;
546 for id in &p.ids {
547 if !end_share(is_admin, *id).await {
548 failed += 1;
549 }
550 }
551 if failed == 0 {
552 show(
553 toast,
554 i18n::t_fmt(i18n::k::DELETED_N_SHARES, &total.to_string()),
555 );
556 } else if failed < total {
557 show(
558 toast,
559 i18n::t_fmt2(
560 i18n::k::DELETE_PARTIAL,
561 &failed.to_string(),
562 &total.to_string(),
563 ),
564 );
565 } else {
566 show_error(toast, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
567 }
568 // Re-read: after a partial failure, editing the list by hand is
569 // not trustworthy.
570 load();
571 set_busy.set(false);
572 });
573 };
574
575 view! {
576 <div class="shares-view">
577 <div class="view-header">
578 <div class="users-header">
579 <h3 class="admin-section-title">
580 {move || {
581 if admin() {
582 i18n::t(i18n::k::ALL_SHARES).to_string()
583 } else {
584 i18n::t(i18n::k::YOUR_SHARES).to_string()
585 }
586 }}
587 </h3>
588 {move || {
589 let n = rows.get().map(|r| r.len()).unwrap_or(0);
590 (n > 0)
591 .then(|| {
592 view! {
593 <button
594 class="btn btn-sm btn-danger"
595 disabled=move || busy.get()
596 on:click=move |_: web_sys::MouseEvent| {
597 let Some(list) = rows.get() else { return };
598 let ids: Vec<i64> =
599 list.iter().map(|r| r.share.id).collect();
600 if ids.is_empty() {
601 return;
602 }
603 set_confirm.set(Some(Pending { ids, who: None }));
604 }
605 >
606 {icon_svg(IconName::Delete, "ic-btn")}
607 {i18n::tr(i18n::k::DELETE_ALL)}
608 </button>
609 }
610 })
611 .into_any()
612 }}
613 </div>
614 <p class="muted view-hint">
615 {move || {
616 if admin() {
617 i18n::t(i18n::k::ALL_SHARES_HINT).to_string()
618 } else {
619 i18n::t(i18n::k::SHARES_VIEW_HINT).to_string()
620 }
621 }}
622 </p>
623 </div>
624 {move || match rows.get() {
625 None => match load_err.get() {
626 Some(msg) => view! {
627 <div class="card error-card">
628 <span>{msg}</span>
629 <button class="btn" on:click=move |_| load()>
630 {i18n::tr(i18n::k::RETRY)}
631 </button>
632 </div>
633 }
634 .into_view()
635 .into_any(),
636 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
637 .into_view()
638 .into_any(),
639 },
640 Some(ref list) if list.is_empty() => view! {
641 <div class="empty-state">
642 {icon_svg(IconName::Share, "empty-glyph")}
643 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
644 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
645 </div>
646 }
647 .into_view()
648 .into_any(),
649 Some(ref list) => {
650 let is_admin = admin();
651 let mine = my_id();
652 let sections = group_rows(list, mine)
653 .into_iter()
654 .map(|g| {
655 let own = g.mine;
656 // One copy, shared by the section title and the
657 // delete-all question: both name the account.
658 let name: Arc<str> = g.name.into();
659 let items = g.shares
660 .iter()
661 .map(|s| share_row(s, toast, delete_one))
662 .collect::<Vec<_>>();
663 let ids: Vec<i64> = g.shares.iter().map(|s| s.id).collect();
664 let n = g.shares.len();
665 let gid = g.id;
666 let inactive = !g.active;
667 view! {
668 // Own links open, everyone else's collapsed: an
669 // admin lands on their own links, not on a wall
670 // of other people's tokens.
671 <details
672 class="share-group"
673 prop:open=move || {
674 open.with(|m| m.get(&gid).copied().unwrap_or(own))
675 }
676 // `<details>` announces every toggle the user
677 // makes, already in its new state.
678 on:toggle=move |ev: web_sys::Event| {
679 let on = ev
680 .target()
681 .and_then(|t| {
682 t.dyn_into::<web_sys::HtmlDetailsElement>().ok()
683 })
684 .is_some_and(|d| d.open());
685 set_open.update(|m| {
686 m.insert(gid, on);
687 });
688 }
689 >
690 <summary class="share-group-head">
691 {icon_svg(IconName::ChevronRight, "share-caret")}
692 <span class="share-group-title">
693 {
694 let name = name.clone();
695 move || {
696 if own {
697 i18n::t(i18n::k::YOUR_SHARES).to_string()
698 } else {
699 name.to_string()
700 }
701 }
702 }
703 </span>
704 // Visible while collapsed: the case the
705 // admin view exists for.
706 {
707 (inactive)
708 .then(|| {
709 view! {
710 <span class="share-group-warn">
711 {i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}
712 </span>
713 }
714 })
715 .into_any()
716 }
717 <span class="share-group-count muted">{n}</span>
718 {
719 // A non-admin has one section, and the heading
720 // button already deletes all of it.
721 let (ids, name) = (ids.clone(), name.clone());
722 is_admin
723 .then(|| {
724 view! {
725 <button
726 class="btn btn-sm btn-danger share-group-del"
727 title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
728 disabled=move || busy.get()
729 on:click=move |ev: web_sys::MouseEvent| {
730 // Otherwise this click toggles the
731 // section it sits in.
732 ev.prevent_default();
733 if ids.is_empty() {
734 return;
735 }
736 set_confirm.set(Some(Pending {
737 ids: ids.clone(),
738 who: (!own).then(|| name.to_string()),
739 }));
740 }
741 >
742 {
743 icon_svg(IconName::Delete, "ic-btn")
744 }
745 {i18n::t(i18n::k::DELETE_ALL)}
746 </button>
747 }
748 })
749 .into_any()
750 }
751 </summary>
752 <div class="share-list">{items}</div>
753 </details>
754 }
755 })
756 .collect::<Vec<_>>();
757 view! { <div class="share-groups">{sections}</div> }
758 .into_view()
759 .into_any()
760 }
761 }}
762
763 // Delete-all confirmation, for one account or for everything.
764 {
765 move || {
766 let Some(p) = confirm.get() else {
767 return view! {}.into_any();
768 };
769 // Name the account: the links being deleted are not the
770 // viewer's.
771 let msg = match &p.who {
772 Some(name) => i18n::t_fmt(i18n::k::DELETE_ALL_USER_MSG, name),
773 None => i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &p.ids.len().to_string()),
774 };
775 view! {
776 <Modal
777 class="card"
778 on_close=Callback::new(move |_| set_confirm.set(None))
779 >
780 <h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
781 <p class="modal-message">{msg}</p>
782 <div class="modal-actions">
783 <button class="btn" on:click=move |_| set_confirm.set(None)>
784 {i18n::tr(i18n::k::CANCEL)}
785 </button>
786 <button
787 class="btn btn-danger"
788 disabled=move || busy.get()
789 on:click=delete_all
790 >
791 {i18n::t(i18n::k::DELETE_ALL)}
792 </button>
793 </div>
794 </Modal>
795 }
796 .into_view()
797 .into_any()
798 }
799 }
800 </div>
801 }
802}
803