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