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 // A profile change must not remount the feeds and reset their groups.
419 let pim_user = Memo::new(move |_| (admin(), my_id()));
420 // None until the first load answers, so "loading" and "no shares" stay
421 // distinguishable.
422 let (rows, set_rows) = signal(Option::<Vec<AdminShare>>::None);
423 // Set when the list could not be loaded; the error card offers a retry.
424 let (load_err, set_load_err) = signal(Option::<String>::None);
425 let (confirm, set_confirm) = signal(None::<Pending>);
426 let (busy, set_busy) = signal(false);
427 // Which sections are open, by account. Kept out of the DOM: the listing is
428 // rebuilt on every delete, and a section must not snap shut under the user.
429 let (open, set_open) = signal(HashMap::<i64, bool>::new());
430
431 let load = move || {
432 set_load_err.set(None);
433 // `try_get`, not `get`: a bulk delete calls this after its awaits, when
434 // the view may be gone. Reading a disposed memo panics and kills the app.
435 let Some(u) = user.try_get() else {
436 return;
437 };
438 // The own list does not name a creator, so the viewer's own id and name
439 // fill that in. `me` is loaded before this view renders.
440 let is_admin = u.is_admin;
441 let own_id = u.id;
442 let own_name = u.name;
443 spawn_local(async move {
444 let got = if is_admin {
445 api::list_all_shares().await
446 } else {
447 api::list_shares().await.map(|own| {
448 own.into_iter()
449 .map(|share| AdminShare {
450 share,
451 creator_id: own_id,
452 creator_name: own_name.clone(),
453 creator_active: true,
454 })
455 .collect()
456 })
457 };
458 match got {
459 Ok(rs) => set_rows.set(Some(rs)),
460 Err(e) => set_load_err.set(Some(e.to_string())),
461 }
462 });
463 };
464 load();
465
466 let delete_one = Callback::new(move |id: i64| {
467 let is_admin = admin();
468 let toast2 = toast;
469 spawn_local(async move {
470 if end_share(is_admin, id).await {
471 set_rows.update(|rs| {
472 if let Some(list) = rs {
473 list.retain(|r| r.share.id != id);
474 }
475 });
476 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
477 } else {
478 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
479 }
480 });
481 });
482
483 // Bulk delete: no server-side bulk endpoint, so this walks the ids one by one.
484 let delete_all = Callback::new(move |_| {
485 let Some(p) = confirm.get() else { return };
486 set_confirm.set(None);
487 if p.ids.is_empty() || busy.get() {
488 return;
489 }
490 set_busy.set(true);
491 let total = p.ids.len();
492 let is_admin = admin();
493 spawn_local(async move {
494 let mut failed = 0usize;
495 for id in &p.ids {
496 if !end_share(is_admin, *id).await {
497 failed += 1;
498 }
499 }
500 if failed == 0 {
501 show(
502 toast,
503 i18n::t_fmt(i18n::k::DELETED_N_SHARES, &total.to_string()),
504 );
505 } else if failed < total {
506 show(
507 toast,
508 i18n::t_fmt2(
509 i18n::k::DELETE_PARTIAL,
510 &failed.to_string(),
511 &total.to_string(),
512 ),
513 );
514 } else {
515 show_error(toast, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
516 }
517 // Re-read: after a partial failure, editing the list by hand is
518 // not trustworthy.
519 load();
520 set_busy.set(false);
521 });
522 });
523
524 view! {
525 <div class="shares-view">
526 <div class="view-header">
527 <div class="users-header">
528 <h3 class="admin-section-title">
529 {move || {
530 if admin() {
531 i18n::t(i18n::k::ALL_SHARES).to_string()
532 } else {
533 i18n::t(i18n::k::YOUR_SHARES).to_string()
534 }
535 }}
536 </h3>
537 {move || {
538 let n = rows.get().map(|r| r.len()).unwrap_or(0);
539 (n > 0)
540 .then(|| {
541 view! {
542 <button
543 class="btn btn-sm btn-danger"
544 disabled=move || busy.get()
545 on:click=move |_: web_sys::MouseEvent| {
546 let Some(list) = rows.get() else { return };
547 let ids: Vec<i64> =
548 list.iter().map(|r| r.share.id).collect();
549 if ids.is_empty() {
550 return;
551 }
552 set_confirm.set(Some(Pending { ids, who: None }));
553 }
554 >
555 {icon_svg(IconName::Delete, "ic-btn")}
556 {i18n::tr(i18n::k::DELETE_ALL)}
557 </button>
558 }
559 })
560 .into_any()
561 }}
562 </div>
563 <p class="muted view-hint">
564 {move || {
565 if admin() {
566 i18n::t(i18n::k::ALL_SHARES_HINT).to_string()
567 } else {
568 i18n::t(i18n::k::SHARES_VIEW_HINT).to_string()
569 }
570 }}
571 </p>
572 </div>
573 {move || match rows.get() {
574 None => match load_err.get() {
575 Some(msg) => view! {
576 <div class="card error-card">
577 <span>{msg}</span>
578 <button class="btn" on:click=move |_| load()>
579 {i18n::tr(i18n::k::RETRY)}
580 </button>
581 </div>
582 }
583 .into_any(),
584 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
585 .into_any(),
586 },
587 Some(ref list) if list.is_empty() => view! {
588 <div class="empty-state">
589 {icon_svg(IconName::Share, "empty-glyph")}
590 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
591 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
592 </div>
593 }
594 .into_any(),
595 Some(ref list) => {
596 let is_admin = admin();
597 let mine = my_id();
598 let sections = group_rows(list, mine)
599 .into_iter()
600 .map(|g| {
601 let own = g.mine;
602 // One copy, shared by the section title and the
603 // delete-all question: both name the account.
604 let name: Arc<str> = g.name.into();
605 let items = g.shares
606 .iter()
607 .map(|s| share_row(s, toast, delete_one))
608 .collect::<Vec<_>>();
609 let ids: Vec<i64> = g.shares.iter().map(|s| s.id).collect();
610 let n = g.shares.len();
611 let gid = g.id;
612 let inactive = !g.active;
613 view! {
614 // Own links open, everyone else's collapsed: an
615 // admin lands on their own links, not on a wall
616 // of other people's tokens.
617 <details
618 class="share-group"
619 prop:open=move || {
620 open.with(|m| m.get(&gid).copied().unwrap_or(own))
621 }
622 // `<details>` announces every toggle the user
623 // makes, already in its new state.
624 on:toggle=move |ev: web_sys::Event| {
625 let on = ev
626 .target()
627 .and_then(|t| {
628 t.dyn_into::<web_sys::HtmlDetailsElement>().ok()
629 })
630 .is_some_and(|d| d.open());
631 set_open.update(|m| {
632 m.insert(gid, on);
633 });
634 }
635 >
636 <summary class="share-group-head">
637 {icon_svg(IconName::ChevronRight, "share-caret")}
638 <span class="share-group-title">
639 {
640 let name = name.clone();
641 move || {
642 if own {
643 i18n::t(i18n::k::YOUR_SHARES).to_string()
644 } else {
645 name.to_string()
646 }
647 }
648 }
649 </span>
650 // Visible while collapsed: the case the
651 // admin view exists for.
652 {
653 (inactive)
654 .then(|| {
655 view! {
656 <span class="share-group-warn">
657 {i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}
658 </span>
659 }
660 })
661 .into_any()
662 }
663 <span class="share-group-count muted">{n}</span>
664 {
665 // A non-admin has one section, and the heading
666 // button already deletes all of it.
667 let (ids, name) = (ids.clone(), name.clone());
668 is_admin
669 .then(|| {
670 view! {
671 <button
672 class="btn btn-sm btn-danger share-group-del"
673 title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
674 disabled=move || busy.get()
675 on:click=move |ev: web_sys::MouseEvent| {
676 // Otherwise this click toggles the
677 // section it sits in.
678 ev.prevent_default();
679 if ids.is_empty() {
680 return;
681 }
682 set_confirm.set(Some(Pending {
683 ids: ids.clone(),
684 who: (!own).then(|| name.to_string()),
685 }));
686 }
687 >
688 {
689 icon_svg(IconName::Delete, "ic-btn")
690 }
691 {i18n::t(i18n::k::DELETE_ALL)}
692 </button>
693 }
694 })
695 .into_any()
696 }
697 </summary>
698 <div class="share-list">{items}</div>
699 </details>
700 }
701 })
702 .collect::<Vec<_>>();
703 view! { <div class="share-groups">{sections}</div> }
704 .into_any()
705 }
706 }}
707
708 {move || {
709 let (admin, my_id) = pim_user.get();
710 view! { <PimShares admin=admin my_id=my_id/> }
711 }}
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/// Calendar and address book feeds and loans, grouped like the file shares
738/// above. Everyone sees their own; an admin also sees everyone else's feeds.
739/// Loans between other users stay private: they expose nothing publicly.
740#[component]
741fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
742 let toast = use_context::<ToastMsg>().expect("toast context");
743 let (own, set_own) = signal(Option::<PimOwnShares>::None);
744 let (others, set_others) = signal(Vec::<AdminPimLink>::new());
745 // Open sections, kept across reloads like the file shares'. Own groups
746 // use the ids below, other owners their account id.
747 let (open, set_open) = signal(HashMap::<i64, bool>::new());
748 const OWN_LINKS: i64 = -1;
749 const OWN_LENDS: i64 = -2;
750 let (load_err, set_load_err) = signal(Option::<String>::None);
751 let (others_err, set_others_err) = signal(Option::<String>::None);
752 let load_seq = StoredValue::new(0u32);
753 let load = move || {
754 let seq = load_seq.get_value() + 1;
755 load_seq.set_value(seq);
756 set_load_err.set(None);
757 spawn_local(async move {
758 let latest = move || load_seq.try_get_value() == Some(seq);
759 let r = api::pim_own_shares().await;
760 if !latest() {
761 return;
762 }
763 match r {
764 Ok(v) => {
765 let _ = set_own.try_set(Some(v));
766 }
767 Err(e) if own.try_with_untracked(|o| o.is_some()) == Some(true) => {
768 show_error(toast, e.to_string())
769 }
770 Err(e) => {
771 let _ = set_load_err.try_set(Some(e.to_string()));
772 }
773 }
774 if admin {
775 let r = api::admin_pim_links().await;
776 if !latest() {
777 return;
778 }
779 match r {
780 Ok(v) => {
781 let v = v.into_iter().filter(|f| f.owner_id != my_id).collect();
782 let _ = set_others.try_set(v);
783 let _ = set_others_err.try_set(None);
784 }
785 Err(e) => {
786 let _ = set_others_err.try_set(Some(e.to_string()));
787 }
788 }
789 }
790 });
791 };
792 load();
793 let glyph = |kind: api::PimCollectionKind| match kind {
794 api::PimCollectionKind::Calendar => IconName::Calendar,
795 api::PimCollectionKind::Addressbook => IconName::Contacts,
796 };
797 // Own feeds end through the collection's route, other owners' through
798 // the admin route.
799 let feed_row = move |f: &AdminPimLink| {
800 let url = crate::router::server_url(&f.link.path);
801 let copy = url.clone();
802 let calendar = f.kind == api::PimCollectionKind::Calendar;
803 let subscribe = calendar.then(|| webcal(&url)).flatten();
804 let (id, cid, mine) = (f.link.id, f.collection_id, f.owner_id == my_id);
805 let mut meta = Vec::new();
806 // Address book feeds have no detail level.
807 if calendar {
808 meta.push(if f.link.busy_only {
809 i18n::t(i18n::k::PIM_LINK_BUSY).to_string()
810 } else {
811 i18n::t(i18n::k::PIM_LINK_FULL).to_string()
812 });
813 }
814 if let Some(e) = &f.link.expires_at {
815 meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &local_date_time(e)));
816 }
817 meta.push(i18n::t_fmt(
818 i18n::k::META_CREATED,
819 &local_date_time(&f.link.created_at),
820 ));
821 if f.link.has_password {
822 meta.push(i18n::t(i18n::k::SHARE_PROTECTED).to_string());
823 }
824 view! {
825 <div class="share-item">
826 <div class="share-item-main">
827 <div class="share-item-name">
828 {icon_svg(glyph(f.kind), "ic-row")}
829 <span>{f.collection_name.clone()}</span>
830 </div>
831 <div class="share-item-meta muted">{meta.join(" · ")}</div>
832 </div>
833 <div class="share-item-actions">
834 <button
835 class="btn btn-sm"
836 on:click=move |_| copy_to_clipboard(&copy, i18n::t(i18n::k::LINK_COPIED), toast)
837 >
838 {icon_svg(IconName::Copy, "ic-btn")}
839 {i18n::t(i18n::k::COPY)}
840 </button>
841 {subscribe.map(|w| view! {
842 <a class="btn btn-sm" href=w>{i18n::t(i18n::k::PIM_SUBSCRIBE)}</a>
843 })}
844 <button
845 class="btn btn-sm btn-danger"
846 on:click=move |_| {
847 spawn_local(async move {
848 let done = if mine {
849 api::pim_delete_link(cid, id).await
850 } else {
851 api::admin_delete_pim_link(id).await
852 };
853 match done {
854 Ok(_) => {
855 let _ = set_own.try_update(|o| {
856 if let Some(o) = o {
857 o.links.retain(|l| l.link.id != id);
858 }
859 });
860 let _ = set_others.try_update(|o| o.retain(|l| l.link.id != id));
861 show(toast, i18n::t(i18n::k::PIM_LINK_REVOKED).to_string());
862 }
863 Err(e) => show_error(toast, e.to_string()),
864 }
865 load();
866 });
867 }
868 >
869 {icon_svg(IconName::Delete, "ic-btn")}
870 {i18n::t(i18n::k::DELETE)}
871 </button>
872 </div>
873 </div>
874 }
875 .into_any()
876 };
877 let lend_row = move |l: &PimLend| {
878 let (cid, uid, who) = (l.collection_id, l.share.user_id, l.share.user_name.clone());
879 let modes: &[PimShareMode] = match l.kind {
880 api::PimCollectionKind::Calendar => {
881 &[PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule]
882 }
883 api::PimCollectionKind::Addressbook => &[PimShareMode::Ro, PimShareMode::Rw],
884 };
885 let current = l.share.mode;
886 view! {
887 <div class="share-item">
888 <div class="share-item-main">
889 <div class="share-item-name">
890 {icon_svg(glyph(l.kind), "ic-row")}
891 <span>{l.collection_name.clone()}</span>
892 </div>
893 <div class="share-item-meta muted">{l.share.user_name.clone()}</div>
894 </div>
895 <div class="share-item-actions">
896 <select
897 class="pim-mode"
898 aria-label=i18n::tr(i18n::k::PIM_ACCESS)
899 on:change=move |ev| {
900 let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) else {
901 return;
902 };
903 let who = who.clone();
904 spawn_local(async move {
905 if let Err(e) = api::pim_share(cid, &who, m).await {
906 show_error(toast, e.to_string());
907 }
908 // Also on failure, so the select shows the real mode.
909 load();
910 });
911 }
912 >
913 {modes
914 .iter()
915 .map(|&m| view! {
916 <option value=m.as_str() selected=m == current>{mode_label(m)}</option>
917 })
918 .collect::<Vec<_>>()}
919 </select>
920 <button
921 class="btn btn-sm btn-danger"
922 on:click=move |_| {
923 spawn_local(async move {
924 match api::pim_unshare(cid, uid).await {
925 Ok(_) => {
926 let _ = set_own.try_update(|o| {
927 if let Some(o) = o {
928 o.lends.retain(|l| {
929 (l.collection_id, l.share.user_id) != (cid, uid)
930 });
931 }
932 });
933 show(toast, i18n::t(i18n::k::PIM_UNSHARED).to_string());
934 }
935 Err(e) => show_error(toast, e.to_string()),
936 }
937 load();
938 });
939 }
940 >
941 {icon_svg(IconName::Delete, "ic-btn")}
942 {i18n::t(i18n::k::PIM_UNSHARE)}
943 </button>
944 </div>
945 </div>
946 }
947 .into_any()
948 };
949 let group = move |gid: i64, title: String, warn: bool, rows: Vec<AnyView>| {
950 let n = rows.len();
951 view! {
952 <details
953 class="share-group"
954 prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(gid < 0))
955 on:toggle=move |ev: web_sys::Event| {
956 let on = ev
957 .target()
958 .and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
959 .is_some_and(|d| d.open());
960 set_open.update(|m| {
961 m.insert(gid, on);
962 });
963 }
964 >
965 <summary class="share-group-head">
966 {icon_svg(IconName::ChevronRight, "share-caret")}
967 <span class="share-group-title">{title}</span>
968 {warn.then(|| view! {
969 <span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
970 })}
971 <span class="share-group-count muted">{n}</span>
972 </summary>
973 <div class="share-list">{rows}</div>
974 </details>
975 }
976 };
977 view! {
978 <div class="view-header feeds-header">
979 <h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_NAV)}</h3>
980 <p class="muted view-hint">{i18n::tr(i18n::k::PIM_OWN_HINT)}</p>
981 </div>
982 {move || {
983 let Some(own) = own.get() else {
984 return match load_err.get() {
985 Some(msg) => view! {
986 <div class="card error-card">
987 <span>{msg}</span>
988 <button class="btn" on:click=move |_| load()>
989 {i18n::tr(i18n::k::RETRY)}
990 </button>
991 </div>
992 }
993 .into_any(),
994 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
995 };
996 };
997 let others = others.get();
998 let others_err = others_err.get().map(|msg| view! {
999 <div class="card error-card">
1000 <span>{msg}</span>
1001 <button class="btn" on:click=move |_| load()>
1002 {i18n::tr(i18n::k::RETRY)}
1003 </button>
1004 </div>
1005 });
1006 if own.links.is_empty() && own.lends.is_empty() && others.is_empty() {
1007 return view! {
1008 {others_err}
1009 <p class="muted">{i18n::tr(i18n::k::PIM_OWN_EMPTY)}</p>
1010 }
1011 .into_any();
1012 }
1013 let mut sections = Vec::new();
1014 if !own.links.is_empty() {
1015 let rows = own.links.iter().map(feed_row).collect();
1016 sections.push(group(OWN_LINKS, i18n::t(i18n::k::PIM_OWN_LINKS).to_string(), false, rows));
1017 }
1018 if !own.lends.is_empty() {
1019 let rows = own.lends.iter().map(lend_row).collect();
1020 sections.push(group(OWN_LENDS, i18n::t(i18n::k::PIM_OWN_LENDS).to_string(), false, rows));
1021 }
1022 // Other owners' feeds, by name, collapsed.
1023 let mut by_owner: BTreeMap<(String, i64), Vec<AdminPimLink>> = BTreeMap::new();
1024 for f in others {
1025 by_owner.entry((f.owner_name.to_lowercase(), f.owner_id)).or_default().push(f);
1026 }
1027 for ((_, gid), items) in by_owner {
1028 let title = i18n::t_fmt(i18n::k::PIM_FEEDS_OF, &items[0].owner_name);
1029 let rows = items.iter().map(feed_row).collect();
1030 sections.push(group(gid, title, !items[0].owner_active, rows));
1031 }
1032 view! { {others_err} <div class="share-groups">{sections}</div> }.into_any()
1033 }}
1034 }
1035}
1036