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