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