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