pim.rs
⎇
Raw
1//! Calendars and address books: the collection list beside each view, the
2//! dialog that manages one collection, the client setup help and the import
3//! of an `.ics` or `.vcf` file.
4
5use std::collections::HashSet;
6
7use api_types::{CreatePimCollection, CreatePimLink, UpdatePimCollection};
8use leptos::prelude::*;
9use wasm_bindgen_futures::spawn_local;
10
11use crate::api::{
12 self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
13 PimShareInfo, PimShareMode,
14};
15use crate::components::icon::{Icon, icon_svg};
16use crate::components::modal::Modal;
17use crate::components::toast::{ToastMsg, show, show_error};
18use crate::i18n::{self, k};
19use crate::icons::IconName;
20use crate::router::{Location, Section, navigate, server_url};
21use crate::util::{copy_to_clipboard, format_date, storage_get, storage_set};
22use crate::views::dialogs::{ConfirmDialog, Dialog};
23use crate::views::shares::expiry_to_rfc3339;
24
25/// Colors offered for a collection; a custom one comes from the color input.
26const PALETTE: [&str; 8] = [
27 "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b",
28];
29
30/// The collections the lists do not show, by id.
31const HIDDEN_KEY: &str = "fbng.pim-hidden";
32
33/// Collection ids hidden in the calendar and contact views.
34pub fn load_hidden() -> HashSet<i64> {
35 storage_get(HIDDEN_KEY)
36 .unwrap_or_default()
37 .split(',')
38 .filter_map(|s| s.parse().ok())
39 .collect()
40}
41
42fn save_hidden(ids: &HashSet<i64>) {
43 let list: Vec<String> = ids.iter().map(i64::to_string).collect();
44 storage_set(HIDDEN_KEY, &list.join(","));
45}
46
47/// A collection's color for a style attribute. Colors of lent collections
48/// come from other clients, so anything but a hex color falls back.
49pub fn safe_color(c: Option<&str>) -> String {
50 match c.and_then(|c| c.strip_prefix('#')) {
51 Some(h) if [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()) => {
52 format!("#{h}")
53 }
54 _ => "var(--accent)".to_string(),
55 }
56}
57
58/// The name to show: generated collections have translated names.
59pub fn display_name(c: &PimCollectionInfo) -> String {
60 match (c.generated, c.kind) {
61 (true, PimCollectionKind::Calendar) => i18n::t(k::PIM_BIRTHDAYS).to_string(),
62 (true, PimCollectionKind::Addressbook) => i18n::t(k::PIM_DIRECTORY).to_string(),
63 _ => c.name.clone(),
64 }
65}
66
67/// Whether the signed-in user may add objects to a collection.
68pub fn writable(c: &PimCollectionInfo) -> bool {
69 !c.generated && c.mode != Some(PimShareMode::Ro)
70}
71
72fn mode_label(m: PimShareMode) -> &'static str {
73 match m {
74 PimShareMode::Ro => i18n::t(k::PIM_MODE_RO),
75 PimShareMode::Rw => i18n::t(k::PIM_MODE_RW),
76 PimShareMode::RwSchedule => i18n::t(k::PIM_MODE_SCHEDULE),
77 }
78}
79
80/// The browser's IANA time zone, for dates the server resolves.
81pub fn browser_tz() -> String {
82 let opts = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new())
83 .resolved_options();
84 js_sys::Reflect::get(&opts, &"timeZone".into())
85 .ok()
86 .and_then(|v| v.as_string())
87 .unwrap_or_else(|| "UTC".to_string())
88}
89
90/// The calendar or contacts section: the collection list, and the view of
91/// what the visible collections hold.
92#[component]
93pub fn PimView(
94 kind: PimCollectionKind,
95 me: ReadSignal<Option<Me>>,
96 set_dialog: WriteSignal<Option<Dialog>>,
97) -> impl IntoView {
98 let (collections, set_collections) = signal(Option::<Vec<PimCollectionInfo>>::None);
99 let (load_err, set_load_err) = signal(Option::<String>::None);
100 let hidden = RwSignal::new(load_hidden());
101 let reload = Callback::new(move |_| {
102 set_load_err.set(None);
103 spawn_local(async move {
104 match api::pim_collections().await {
105 Ok(v) => set_collections.set(Some(v)),
106 Err(e) => set_load_err.set(Some(e.to_string())),
107 }
108 });
109 });
110 reload.run(());
111 let (glyph, title, hint) = match kind {
112 PimCollectionKind::Calendar => (IconName::Calendar, k::PIM_CALENDAR, k::PIM_CAL_HINT),
113 PimCollectionKind::Addressbook => {
114 (IconName::Contacts, k::PIM_CONTACTS, k::PIM_CONTACTS_HINT)
115 }
116 };
117 view! {
118 <div class="pim-view">
119 <CollectionList
120 kind=kind
121 collections=collections
122 load_err=load_err
123 hidden=hidden
124 reload=reload
125 me=me
126 set_dialog=set_dialog
127 />
128 <section class="pim-main">
129 <div class="empty-state">
130 {icon_svg(glyph, "empty-glyph")}
131 <h3>{i18n::tr(title)}</h3>
132 <p class="muted">{i18n::tr(hint)}</p>
133 </div>
134 </section>
135 </div>
136 }
137}
138
139#[component]
140fn CollectionList(
141 kind: PimCollectionKind,
142 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
143 load_err: ReadSignal<Option<String>>,
144 hidden: RwSignal<HashSet<i64>>,
145 reload: Callback<()>,
146 me: ReadSignal<Option<Me>>,
147 set_dialog: WriteSignal<Option<Dialog>>,
148) -> impl IntoView {
149 let (managing, set_managing) = signal(Option::<PimCollectionInfo>::None);
150 let (creating, set_creating) = signal(false);
151 let (connecting, set_connecting) = signal(false);
152 let calendars = kind == PimCollectionKind::Calendar;
153 let (title, new_label) = match calendars {
154 true => (k::PIM_CALENDARS, k::PIM_NEW_CALENDAR),
155 false => (k::PIM_ADDRESS_BOOKS, k::PIM_NEW_ADDRESS_BOOK),
156 };
157 let row = move |c: PimCollectionInfo| {
158 let id = c.id;
159 let is_hidden = move || hidden.with(|h| h.contains(&id));
160 let toggle = move |_| {
161 hidden.update(|h| {
162 if !h.remove(&id) {
163 h.insert(id);
164 }
165 save_hidden(h);
166 });
167 };
168 let note = match (c.generated, c.mode) {
169 (true, _) => Some(i18n::t(k::PIM_AUTO).to_string()),
170 (false, Some(_)) => Some(c.owner.clone()),
171 _ => None,
172 };
173 let name = display_name(&c);
174 let color = safe_color(c.color.as_deref());
175 let info = c.clone();
176 view! {
177 <div class="pim-row" class:pim-row-hidden=is_hidden>
178 <button
179 class="pim-toggle"
180 title=move || if is_hidden() { i18n::t(k::PIM_SHOW) } else { i18n::t(k::PIM_HIDE) }
181 aria-pressed=move || (!is_hidden()).to_string()
182 on:click=toggle
183 >
184 <span class="pim-swatch" style=format!("--swatch: {color}")></span>
185 </button>
186 <button
187 class="pim-row-main"
188 title=name.clone()
189 on:click=move |_| set_managing.set(Some(info.clone()))
190 >
191 <span class="pim-row-name">{name.clone()}</span>
192 {note.map(|n| view! { <span class="pim-row-note">{n}</span> })}
193 </button>
194 </div>
195 }
196 };
197 view! {
198 <aside class="pim-side">
199 <div class="pim-side-head">
200 <h3 class="admin-section-title">{i18n::tr(title)}</h3>
201 <button
202 class="icon-btn icon-btn-sm"
203 title=i18n::tr(k::PIM_CONNECT)
204 aria-label=i18n::tr(k::PIM_CONNECT)
205 on:click=move |_| set_connecting.set(true)
206 >
207 <Icon name=IconName::Devices class="ic-btn".to_string()/>
208 </button>
209 <button
210 class="icon-btn icon-btn-sm"
211 title=i18n::tr(new_label)
212 aria-label=i18n::tr(new_label)
213 on:click=move |_| set_creating.set(true)
214 >
215 <Icon name=IconName::Add class="ic-btn".to_string()/>
216 </button>
217 </div>
218 {move || match collections.get() {
219 None => match load_err.get() {
220 Some(msg) => view! {
221 <div class="card error-card">
222 <span>{msg}</span>
223 <button class="btn" on:click=move |_| reload.run(())>
224 {i18n::tr(k::RETRY)}
225 </button>
226 </div>
227 }
228 .into_any(),
229 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
230 },
231 Some(list) => {
232 let (mine, lent): (Vec<_>, Vec<_>) = list
233 .into_iter()
234 .filter(|c| c.kind == kind)
235 .partition(|c| c.mode.is_none());
236 let lent_block = (!lent.is_empty()).then(|| view! {
237 <div class="pim-group-label">{i18n::tr(k::PIM_SHARED_WITH_ME)}</div>
238 {lent.into_iter().map(row).collect::<Vec<_>>()}
239 });
240 view! {
241 <div class="pim-list">
242 {mine.into_iter().map(row).collect::<Vec<_>>()}
243 {lent_block}
244 </div>
245 }
246 .into_any()
247 }
248 }}
249 </aside>
250 {move || managing.get().map(|info| view! {
251 <CollectionDialog
252 info=info
253 me=me
254 set_dialog=set_dialog
255 on_close=Callback::new(move |_| set_managing.set(None))
256 on_changed=reload
257 />
258 })}
259 <Show when=move || creating.get()>
260 <NewCollectionDialog
261 kind=kind
262 on_close=Callback::new(move |_| set_creating.set(false))
263 on_created=reload
264 />
265 </Show>
266 <Show when=move || connecting.get()>
267 <ConnectDialog
268 me=me
269 collections=collections
270 on_close=Callback::new(move |_| set_connecting.set(false))
271 />
272 </Show>
273 }
274}
275
276/// The palette plus a custom color. `None` clears the color.
277#[component]
278fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
279 view! {
280 <div class="pim-colors" role="radiogroup" aria-label=i18n::tr(k::PIM_COLOR)>
281 {PALETTE
282 .iter()
283 .map(|&c| {
284 view! {
285 <button
286 type="button"
287 class="pim-color"
288 class:selected=move || color.with(|v| v.as_deref() == Some(c))
289 role="radio"
290 aria-checked=move || color.with(|v| v.as_deref() == Some(c)).to_string()
291 title=c
292 style=format!("--swatch: {c}")
293 on:click=move |_| color.set(Some(c.to_string()))
294 ></button>
295 }
296 })
297 .collect::<Vec<_>>()}
298 <input
299 type="color"
300 class="pim-color-input"
301 title=i18n::tr(k::PIM_CUSTOM_COLOR)
302 aria-label=i18n::tr(k::PIM_CUSTOM_COLOR)
303 prop:value=move || {
304 color.with(|v| {
305 v.as_deref()
306 .filter(|c| c.len() >= 7)
307 .map(|c| c[..7].to_string())
308 .unwrap_or_else(|| "#3b82f6".to_string())
309 })
310 }
311 on:input=move |ev| color.set(Some(event_target_value(&ev)))
312 />
313 </div>
314 }
315}
316
317#[component]
318fn NewCollectionDialog(
319 kind: PimCollectionKind,
320 on_close: Callback<()>,
321 on_created: Callback<()>,
322) -> impl IntoView {
323 let toast = use_context::<ToastMsg>().expect("toast context");
324 let (name, set_name) = signal(String::new());
325 let color = RwSignal::new(Some(PALETTE[0].to_string()));
326 let (description, set_description) = signal(String::new());
327 let (busy, set_busy) = signal(false);
328 let title = match kind {
329 PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR,
330 PimCollectionKind::Addressbook => k::PIM_NEW_ADDRESS_BOOK,
331 };
332 let submit = move |ev: web_sys::SubmitEvent| {
333 ev.prevent_default();
334 let n = name.get().trim().to_string();
335 if n.is_empty() || busy.get() {
336 return;
337 }
338 set_busy.set(true);
339 let body = CreatePimCollection {
340 kind,
341 name: n,
342 color: color.get(),
343 description: Some(description.get()).filter(|d| !d.trim().is_empty()),
344 components: Vec::new(),
345 };
346 spawn_local(async move {
347 match api::pim_create_collection(body).await {
348 Ok(_) => {
349 show(toast, i18n::t(k::PIM_CREATED));
350 on_created.run(());
351 on_close.run(());
352 }
353 Err(e) => {
354 show_error(toast, e.to_string());
355 set_busy.set(false);
356 }
357 }
358 });
359 };
360 view! {
361 <Modal class="pim-modal" on_close=on_close>
362 <form on:submit=submit>
363 <h2 class="modal-title">{i18n::tr(title)}</h2>
364 <label class="field">
365 <span class="field-label">{i18n::tr(k::NAME)}</span>
366 <input
367 class="field-input"
368 required=true
369 maxlength="200"
370 autofocus=true
371 prop:value=move || name.get()
372 on:input=move |ev| set_name.set(event_target_value(&ev))
373 />
374 </label>
375 <div class="field">
376 <span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
377 <ColorPicker color=color/>
378 </div>
379 <label class="field">
380 <span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
381 <textarea
382 class="field-input"
383 rows="2"
384 prop:value=move || description.get()
385 on:input=move |ev| set_description.set(event_target_value(&ev))
386 ></textarea>
387 </label>
388 <div class="modal-actions">
389 <button type="button" class="btn" on:click=move |_| on_close.run(())>
390 {i18n::tr(k::CANCEL)}
391 </button>
392 <button type="submit" class="btn btn-primary" disabled=move || busy.get()>
393 {i18n::tr(k::CREATE)}
394 </button>
395 </div>
396 </form>
397 </Modal>
398 }
399}
400
401/// A read-only URL with a copy button.
402fn url_row(url: String, toast: ToastMsg) -> impl IntoView {
403 let copy = url.clone();
404 view! {
405 <div class="share-link-row">
406 <input
407 class="share-link-input"
408 readonly=true
409 value=url
410 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
411 />
412 <button
413 type="button"
414 class="btn"
415 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
416 >
417 {icon_svg(IconName::Copy, "ic-btn")}
418 {i18n::t(k::COPY)}
419 </button>
420 </div>
421 }
422}
423
424/// Everything about one collection: its settings, who it is lent to, its
425/// public links, export and deletion.
426#[component]
427fn CollectionDialog(
428 info: PimCollectionInfo,
429 me: ReadSignal<Option<Me>>,
430 set_dialog: WriteSignal<Option<Dialog>>,
431 on_close: Callback<()>,
432 on_changed: Callback<()>,
433) -> impl IntoView {
434 let toast = use_context::<ToastMsg>().expect("toast context");
435 let own = info.mode.is_none() && !info.generated;
436 let url = server_url(&info.url);
437 let lent_note = info.mode.map(|m| {
438 format!(
439 "{} · {}",
440 i18n::t_fmt(k::PIM_SHARED_BY, &info.owner),
441 mode_label(m)
442 )
443 });
444 let generated_note = info.generated.then(|| i18n::t(k::PIM_GENERATED_HINT));
445 view! {
446 <Modal class="pim-modal" on_close=on_close>
447 <h2 class="modal-title pim-modal-title">
448 <span class="pim-swatch" style=format!("--swatch: {}", safe_color(info.color.as_deref()))></span>
449 <span>{display_name(&info)}</span>
450 </h2>
451 {lent_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
452 {generated_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
453 {own.then(|| view! { <GeneralSection info=info.clone() on_changed=on_changed/> })}
454 <section class="pim-section">
455 <h3 class="pim-section-title">{i18n::tr(k::PIM_ADDRESS)}</h3>
456 {url_row(url, toast)}
457 </section>
458 {own.then(|| view! { <SharingSection info=info.clone()/> })}
459 {own.then(|| view! { <LinksSection info=info.clone()/> })}
460 <ExportSection info=info.clone() me=me set_dialog=set_dialog/>
461 {(!info.generated).then(|| view! {
462 <DeleteSection info=info.clone() on_close=on_close on_changed=on_changed/>
463 })}
464 <div class="modal-actions">
465 <button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
466 </div>
467 </Modal>
468 }
469}
470
471#[component]
472fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
473 let toast = use_context::<ToastMsg>().expect("toast context");
474 let id = info.id;
475 let calendar = info.kind == PimCollectionKind::Calendar;
476 let (name, set_name) = signal(info.name.clone());
477 let color = RwSignal::new(info.color.clone());
478 let (description, set_description) = signal(info.description.clone().unwrap_or_default());
479 let (transparent, set_transparent) = signal(info.transparent);
480 let (busy, set_busy) = signal(false);
481 let save = move |ev: web_sys::SubmitEvent| {
482 ev.prevent_default();
483 if busy.get() || name.get().trim().is_empty() {
484 return;
485 }
486 set_busy.set(true);
487 let body = UpdatePimCollection {
488 name: Some(name.get()),
489 color: Some(color.get().unwrap_or_default()),
490 description: Some(description.get()),
491 transparent: calendar.then(|| transparent.get()),
492 };
493 spawn_local(async move {
494 match api::pim_update_collection(id, body).await {
495 Ok(_) => {
496 show(toast, i18n::t(k::SAVED));
497 on_changed.run(());
498 }
499 Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())),
500 }
501 set_busy.set(false);
502 });
503 };
504 view! {
505 <form class="pim-section" on:submit=save>
506 <h3 class="pim-section-title">{i18n::tr(k::PIM_GENERAL)}</h3>
507 <label class="field">
508 <span class="field-label">{i18n::tr(k::NAME)}</span>
509 <input
510 class="field-input"
511 required=true
512 maxlength="200"
513 prop:value=move || name.get()
514 on:input=move |ev| set_name.set(event_target_value(&ev))
515 />
516 </label>
517 <div class="field">
518 <span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
519 <ColorPicker color=color/>
520 </div>
521 <label class="field">
522 <span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
523 <textarea
524 class="field-input"
525 rows="2"
526 prop:value=move || description.get()
527 on:input=move |ev| set_description.set(event_target_value(&ev))
528 ></textarea>
529 </label>
530 {calendar.then(|| view! {
531 <label class="check-row">
532 <input
533 type="checkbox"
534 prop:checked=move || transparent.get()
535 on:change=move |ev| set_transparent.set(event_target_checked(&ev))
536 />
537 {i18n::tr(k::PIM_TRANSPARENT)}
538 </label>
539 })}
540 <div class="pim-section-actions">
541 <button type="submit" class="btn btn-primary btn-sm" disabled=move || busy.get()>
542 {move || if busy.get() { i18n::t(k::SAVING) } else { i18n::t(k::SAVE) }}
543 </button>
544 </div>
545 </form>
546 }
547}
548
549#[component]
550fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
551 let toast = use_context::<ToastMsg>().expect("toast context");
552 let id = info.id;
553 let calendar = info.kind == PimCollectionKind::Calendar;
554 let modes: Vec<PimShareMode> = match calendar {
555 true => vec![PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule],
556 false => vec![PimShareMode::Ro, PimShareMode::Rw],
557 };
558 let (shares, set_shares) = signal(Option::<Vec<PimShareInfo>>::None);
559 let (user, set_user) = signal(String::new());
560 let (mode, set_mode) = signal(PimShareMode::Ro);
561 let load = move || {
562 spawn_local(async move {
563 match api::pim_shares(id).await {
564 Ok(v) => set_shares.set(Some(v)),
565 Err(e) => show_error(toast, e.to_string()),
566 }
567 });
568 };
569 load();
570 let set = move |who: String, m: PimShareMode| {
571 spawn_local(async move {
572 match api::pim_share(id, &who, m).await {
573 Ok(s) => {
574 show(toast, i18n::t_fmt(k::PIM_SHARED_OK, &s.user_name));
575 set_user.set(String::new());
576 load();
577 }
578 Err(e) => show_error(toast, e.to_string()),
579 }
580 });
581 };
582 let add = move |ev: web_sys::SubmitEvent| {
583 ev.prevent_default();
584 let who = user.get().trim().to_string();
585 if !who.is_empty() {
586 set(who, mode.get());
587 }
588 };
589 let mode_options = move |current: PimShareMode| {
590 modes
591 .iter()
592 .map(|&m| {
593 view! {
594 <option value=m.as_str() selected=m == current>{mode_label(m)}</option>
595 }
596 })
597 .collect::<Vec<_>>()
598 };
599 let mode_options_add = mode_options.clone();
600 view! {
601 <section class="pim-section">
602 <h3 class="pim-section-title">{i18n::tr(k::PIM_SHARING)}</h3>
603 <p class="muted pim-hint">{i18n::tr(k::PIM_SHARING_HINT)}</p>
604 {move || match shares.get() {
605 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
606 Some(list) if list.is_empty() => {
607 view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_SHARES)}</p> }.into_any()
608 }
609 Some(list) => {
610 let options = mode_options.clone();
611 view! {
612 <div class="pim-items">
613 {list
614 .into_iter()
615 .map(|s| {
616 let (uid, who) = (s.user_id, s.user_name.clone());
617 view! {
618 <div class="pim-item">
619 <span class="pim-item-name">{s.user_name.clone()}</span>
620 <select
621 class="pim-mode"
622 aria-label=i18n::tr(k::PIM_ACCESS)
623 on:change=move |ev| {
624 if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
625 set(who.clone(), m);
626 }
627 }
628 >
629 {options(s.mode)}
630 </select>
631 <button
632 type="button"
633 class="icon-btn icon-btn-sm"
634 title=i18n::tr(k::PIM_UNSHARE)
635 aria-label=i18n::tr(k::PIM_UNSHARE)
636 on:click=move |_| {
637 spawn_local(async move {
638 match api::pim_unshare(id, uid).await {
639 Ok(_) => {
640 show(toast, i18n::t(k::PIM_UNSHARED));
641 load();
642 }
643 Err(e) => show_error(toast, e.to_string()),
644 }
645 });
646 }
647 >
648 <Icon name=IconName::Close class="ic-btn".to_string()/>
649 </button>
650 </div>
651 }
652 })
653 .collect::<Vec<_>>()}
654 </div>
655 }
656 .into_any()
657 }
658 }}
659 <form class="pim-add" on:submit=add>
660 <input
661 class="field-input"
662 required=true
663 placeholder=i18n::tr(k::PIM_USER_NAME)
664 aria-label=i18n::tr(k::PIM_USER_NAME)
665 autocomplete="off"
666 prop:value=move || user.get()
667 on:input=move |ev| set_user.set(event_target_value(&ev))
668 />
669 <select
670 class="pim-mode"
671 aria-label=i18n::tr(k::PIM_ACCESS)
672 on:change=move |ev| {
673 if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
674 set_mode.set(m);
675 }
676 }
677 >
678 {mode_options_add(PimShareMode::Ro)}
679 </select>
680 <button type="submit" class="btn btn-sm">{i18n::tr(k::PIM_ADD)}</button>
681 </form>
682 </section>
683 }
684}
685
686#[component]
687fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
688 let toast = use_context::<ToastMsg>().expect("toast context");
689 let id = info.id;
690 let calendar = info.kind == PimCollectionKind::Calendar;
691 let (links, set_links) = signal(Option::<Vec<PimLinkInfo>>::None);
692 let (busy_only, set_busy_only) = signal(false);
693 let (choice, set_choice) = signal("never".to_string());
694 let (custom, set_custom) = signal(String::new());
695 let (password, set_password) = signal(String::new());
696 let (busy, set_busy) = signal(false);
697 let load = move || {
698 spawn_local(async move {
699 match api::pim_links(id).await {
700 Ok(v) => set_links.set(Some(v)),
701 Err(e) => show_error(toast, e.to_string()),
702 }
703 });
704 };
705 load();
706 let create = move |ev: web_sys::SubmitEvent| {
707 ev.prevent_default();
708 if busy.get() {
709 return;
710 }
711 set_busy.set(true);
712 let pw = password.get().trim().to_string();
713 let body = CreatePimLink {
714 busy_only: busy_only.get(),
715 expires_at: expiry_to_rfc3339(&choice.get(), &custom.get()),
716 password: (!pw.is_empty()).then_some(pw),
717 };
718 spawn_local(async move {
719 match api::pim_create_link(id, body).await {
720 Ok(_) => {
721 set_password.set(String::new());
722 load();
723 }
724 Err(e) => show_error(toast, e.to_string()),
725 }
726 set_busy.set(false);
727 });
728 };
729 let link_row = move |l: PimLinkInfo| {
730 let full = server_url(&l.path);
731 // `webcal://` opens the calendar app; the transport stays HTTP(S).
732 let webcal = full
733 .strip_prefix("https://")
734 .or_else(|| full.strip_prefix("http://"))
735 .map(|rest| format!("webcal://{rest}"));
736 let mut meta = vec![if l.busy_only {
737 i18n::t(k::PIM_LINK_BUSY).to_string()
738 } else {
739 i18n::t(k::PIM_LINK_FULL).to_string()
740 }];
741 if let Some(e) = &l.expires_at {
742 meta.push(i18n::t_fmt(k::META_EXPIRES, &format_date(e)));
743 }
744 if l.has_password {
745 meta.push(i18n::t(k::SHARE_PROTECTED).to_string());
746 }
747 let link_id = l.id;
748 let copy = full.clone();
749 view! {
750 <div class="pim-link">
751 <div class="pim-link-main">
752 <span class="pim-link-url" title=full.clone()>{full.clone()}</span>
753 <span class="muted pim-link-meta">{meta.join(" · ")}</span>
754 </div>
755 <div class="pim-link-actions">
756 <button
757 type="button"
758 class="btn btn-sm"
759 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
760 >
761 {icon_svg(IconName::Copy, "ic-btn")}
762 {i18n::t(k::COPY)}
763 </button>
764 {calendar.then(|| webcal.map(|w| view! {
765 <a class="btn btn-sm" href=w>{i18n::t(k::PIM_SUBSCRIBE)}</a>
766 }))}
767 <button
768 type="button"
769 class="btn btn-sm btn-danger"
770 on:click=move |_| {
771 spawn_local(async move {
772 match api::pim_delete_link(id, link_id).await {
773 Ok(_) => {
774 show(toast, i18n::t(k::PIM_LINK_REVOKED));
775 load();
776 }
777 Err(e) => show_error(toast, e.to_string()),
778 }
779 });
780 }
781 >
782 {i18n::t(k::DELETE)}
783 </button>
784 </div>
785 </div>
786 }
787 };
788 view! {
789 <section class="pim-section">
790 <h3 class="pim-section-title">{i18n::tr(k::PIM_LINKS)}</h3>
791 <p class="muted pim-hint">
792 {move || match calendar {
793 true => i18n::t(k::PIM_LINKS_HINT),
794 false => i18n::t(k::PIM_LINKS_HINT_CARDS),
795 }}
796 </p>
797 {move || match links.get() {
798 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
799 Some(list) if list.is_empty() => {
800 view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_LINKS)}</p> }.into_any()
801 }
802 Some(list) => view! {
803 <div class="pim-items">{list.into_iter().map(link_row).collect::<Vec<_>>()}</div>
804 }
805 .into_any(),
806 }}
807 <form class="pim-link-form" on:submit=create>
808 {calendar.then(|| view! {
809 <label class="pim-inline">
810 <span>{i18n::tr(k::PIM_DETAIL)}</span>
811 <select on:change=move |ev| set_busy_only.set(event_target_value(&ev) == "busy")>
812 <option value="full">{i18n::t(k::PIM_DETAIL_FULL)}</option>
813 <option value="busy">{i18n::t(k::PIM_DETAIL_BUSY)}</option>
814 </select>
815 </label>
816 })}
817 <label class="pim-inline">
818 <span>{i18n::tr(k::EXPIRES)}</span>
819 <select on:change=move |ev| set_choice.set(event_target_value(&ev))>
820 <option value="never">{i18n::t(k::NEVER)}</option>
821 <option value="1d">{i18n::t(k::EXPIRY_1D)}</option>
822 <option value="1w">{i18n::t(k::EXPIRY_1W)}</option>
823 <option value="1mo">{i18n::t(k::EXPIRY_1MO)}</option>
824 <option value="custom">{i18n::t(k::CUSTOM)}</option>
825 </select>
826 </label>
827 <Show when=move || choice.get() == "custom">
828 <label class="pim-inline">
829 <span>{i18n::tr(k::UNTIL)}</span>
830 <input
831 type="datetime-local"
832 prop:value=move || custom.get()
833 on:change=move |ev| set_custom.set(event_target_value(&ev))
834 />
835 </label>
836 </Show>
837 <label class="pim-inline">
838 <span>{i18n::tr(k::PASSWORD)}</span>
839 <input
840 type="password"
841 autocomplete="new-password"
842 prop:value=move || password.get()
843 on:input=move |ev| set_password.set(event_target_value(&ev))
844 />
845 </label>
846 <div class="pim-section-actions">
847 <button type="submit" class="btn btn-sm" disabled=move || busy.get()>
848 {i18n::tr(k::CREATE_LINK)}
849 </button>
850 </div>
851 </form>
852 </section>
853 }
854}
855
856/// A file name for an export: the collection name without path separators.
857fn export_file(info: &PimCollectionInfo) -> String {
858 let ext = match info.kind {
859 PimCollectionKind::Calendar => "ics",
860 PimCollectionKind::Addressbook => "vcf",
861 };
862 let base: String = display_name(info)
863 .chars()
864 .map(|c| {
865 if matches!(c, '/' | '\\' | ':') {
866 '_'
867 } else {
868 c
869 }
870 })
871 .collect();
872 format!("{}.{ext}", base.trim())
873}
874
875#[component]
876fn ExportSection(
877 info: PimCollectionInfo,
878 me: ReadSignal<Option<Me>>,
879 set_dialog: WriteSignal<Option<Dialog>>,
880) -> impl IntoView {
881 let toast = use_context::<ToastMsg>().expect("toast context");
882 let id = info.id;
883 let file = export_file(&info);
884 let download = {
885 let file = file.clone();
886 move |_| api::trigger_download(&api::pim_export_url(id), &file)
887 };
888 let save = move |_| {
889 let roots: Vec<_> = me.with_untracked(|m| {
890 m.as_ref()
891 .map(|m| {
892 m.roots
893 .iter()
894 .filter(|r| r.mode.is_writable())
895 .cloned()
896 .collect()
897 })
898 .unwrap_or_default()
899 });
900 let Some(first) = roots.first().map(|r| r.id) else {
901 show_error(toast, i18n::t(k::PIM_NO_WRITABLE_FOLDER));
902 return;
903 };
904 let file = file.clone();
905 set_dialog.set(Some(Dialog::Picker {
906 title: i18n::t(k::PIM_SAVE_TO_FOLDER).to_string(),
907 confirm: i18n::t(k::PIM_SAVE_HERE).to_string(),
908 roots,
909 root: first,
910 dir: String::new(),
911 for_write: false,
912 on_pick: Callback::new(move |(root, dir): (i64, String)| {
913 let path = match dir.is_empty() {
914 true => file.clone(),
915 false => format!("{dir}/{file}"),
916 };
917 spawn_local(async move {
918 match api::pim_export_to_root(id, root, &path).await {
919 Ok(_) => show(toast, i18n::t_fmt(k::PIM_EXPORTED, &path)),
920 Err(e) => show_error(toast, e.to_string()),
921 }
922 });
923 }),
924 }));
925 };
926 view! {
927 <section class="pim-section">
928 <h3 class="pim-section-title">{i18n::tr(k::PIM_EXPORT)}</h3>
929 <div class="pim-section-actions pim-left">
930 <button type="button" class="btn btn-sm" on:click=download>
931 {icon_svg(IconName::Download, "ic-btn")}
932 {i18n::t(k::DOWNLOAD)}
933 </button>
934 <button type="button" class="btn btn-sm" on:click=save>
935 {icon_svg(IconName::Folder, "ic-btn")}
936 {i18n::t(k::PIM_SAVE_TO_FOLDER)}
937 </button>
938 </div>
939 </section>
940 }
941}
942
943#[component]
944fn DeleteSection(
945 info: PimCollectionInfo,
946 on_close: Callback<()>,
947 on_changed: Callback<()>,
948) -> impl IntoView {
949 let toast = use_context::<ToastMsg>().expect("toast context");
950 let (confirm, set_confirm) = signal(false);
951 let id = info.id;
952 let lent = info.mode.is_some();
953 let name = display_name(&info);
954 let run = Callback::new(move |_| {
955 set_confirm.set(false);
956 spawn_local(async move {
957 match api::pim_delete_collection(id).await {
958 Ok(_) => {
959 show(
960 toast,
961 match lent {
962 true => i18n::t(k::PIM_REMOVED),
963 false => i18n::t(k::DELETED),
964 },
965 );
966 on_changed.run(());
967 on_close.run(());
968 }
969 Err(e) => show_error(toast, e.to_string()),
970 }
971 });
972 });
973 view! {
974 <section class="pim-section">
975 {match (lent, info.is_default) {
976 (_, true) => view! { <p class="muted pim-hint">{i18n::tr(k::PIM_DEFAULT_HINT)}</p> }.into_any(),
977 (true, _) => view! {
978 <button type="button" class="btn btn-sm" on:click=move |_| set_confirm.set(true)>
979 {i18n::tr(k::PIM_REMOVE_FROM_LIST)}
980 </button>
981 }
982 .into_any(),
983 (false, _) => view! {
984 <button type="button" class="btn btn-sm btn-danger" on:click=move |_| set_confirm.set(true)>
985 {icon_svg(IconName::Delete, "ic-btn")}
986 {i18n::t(k::DELETE)}
987 </button>
988 }
989 .into_any(),
990 }}
991 </section>
992 <Show when=move || confirm.get()>
993 <ConfirmDialog
994 title=i18n::t_fmt(k::PIM_DELETE_TITLE, &name)
995 message=match lent {
996 true => i18n::t(k::PIM_REMOVE_MSG).to_string(),
997 false => i18n::t(k::PIM_DELETE_MSG).to_string(),
998 }
999 submit=match lent {
1000 true => i18n::t(k::PIM_REMOVE_FROM_LIST).to_string(),
1001 false => i18n::t(k::DELETE).to_string(),
1002 }
1003 danger=!lent
1004 on_submit=run
1005 on_close=Callback::new(move |_| set_confirm.set(false))
1006 />
1007 </Show>
1008 }
1009}
1010
1011/// How to set up a calendar or contacts app against this server.
1012#[component]
1013fn ConnectDialog(
1014 me: ReadSignal<Option<Me>>,
1015 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1016 on_close: Callback<()>,
1017) -> impl IntoView {
1018 let toast = use_context::<ToastMsg>().expect("toast context");
1019 let user = me.with_untracked(|m| {
1020 m.as_ref()
1021 .and_then(|m| m.user.as_ref())
1022 .map(|u| u.name.clone())
1023 .unwrap_or_default()
1024 });
1025 let app_passwords = move |_| {
1026 on_close.run(());
1027 navigate(&Location {
1028 section: Section::Settings,
1029 path: vec!["security".to_string()],
1030 ..Location::root()
1031 });
1032 };
1033 view! {
1034 <Modal class="pim-modal" on_close=on_close>
1035 <h2 class="modal-title">{i18n::tr(k::PIM_CONNECT_TITLE)}</h2>
1036 <section class="pim-section">
1037 <h3 class="pim-section-title">{i18n::tr(k::PIM_SERVER_ADDRESS)}</h3>
1038 {url_row(server_url("/"), toast)}
1039 <p class="muted pim-hint">{i18n::tr(k::PIM_SERVER_ADDRESS_HINT)}</p>
1040 {url_row(server_url(&format!("{}/", api_types::PIM)), toast)}
1041 </section>
1042 <section class="pim-section">
1043 <h3 class="pim-section-title">{i18n::tr(k::PIM_USER)}</h3>
1044 {url_row(user, toast)}
1045 <p class="muted pim-hint">{i18n::tr(k::PIM_PASSWORD_HINT)}</p>
1046 <div class="pim-section-actions pim-left">
1047 <button type="button" class="btn btn-sm" on:click=app_passwords>
1048 {i18n::tr(k::PIM_APP_PASSWORDS)}
1049 </button>
1050 </div>
1051 </section>
1052 <section class="pim-section">
1053 <h3 class="pim-section-title">{i18n::tr(k::PIM_CLIENTS)}</h3>
1054 <ul class="pim-hints">
1055 <li>{i18n::tr(k::PIM_HINT_APPLE)}</li>
1056 <li>{i18n::tr(k::PIM_HINT_DAVX5)}</li>
1057 <li>{i18n::tr(k::PIM_HINT_THUNDERBIRD)}</li>
1058 <li>{i18n::tr(k::PIM_HINT_GNOME)}</li>
1059 </ul>
1060 </section>
1061 <section class="pim-section">
1062 <h3 class="pim-section-title">{i18n::tr(k::PIM_COLLECTION_URLS)}</h3>
1063 <div class="pim-items">
1064 {move || {
1065 collections
1066 .get()
1067 .unwrap_or_default()
1068 .into_iter()
1069 .map(|c| {
1070 let url = server_url(&c.url);
1071 let copy = url.clone();
1072 view! {
1073 <div class="pim-item">
1074 <span class="pim-swatch" style=format!("--swatch: {}", safe_color(c.color.as_deref()))></span>
1075 <span class="pim-item-name" title=url.clone()>{display_name(&c)}</span>
1076 <button
1077 type="button"
1078 class="btn btn-sm"
1079 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
1080 >
1081 {icon_svg(IconName::Copy, "ic-btn")}
1082 {i18n::t(k::COPY)}
1083 </button>
1084 </div>
1085 }
1086 })
1087 .collect::<Vec<_>>()
1088 }}
1089 </div>
1090 </section>
1091 <div class="modal-actions">
1092 <button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
1093 </div>
1094 </Modal>
1095 }
1096}
1097
1098/// Whether a file name is one the import takes, and as what.
1099pub fn import_kind(name: &str) -> Option<PimCollectionKind> {
1100 let lower = name.to_ascii_lowercase();
1101 if lower.ends_with(".ics") || lower.ends_with(".ical") || lower.ends_with(".ifb") {
1102 Some(PimCollectionKind::Calendar)
1103 } else if lower.ends_with(".vcf") || lower.ends_with(".vcard") {
1104 Some(PimCollectionKind::Addressbook)
1105 } else {
1106 None
1107 }
1108}
1109
1110/// Import a file of a root into one of the user's calendars or address
1111/// books, after a look at what it holds.
1112#[component]
1113pub fn PimImportDialog(
1114 root_id: i64,
1115 path: String,
1116 name: String,
1117 on_close: Callback<()>,
1118) -> impl IntoView {
1119 let toast = use_context::<ToastMsg>().expect("toast context");
1120 let kind = import_kind(&name).unwrap_or(PimCollectionKind::Calendar);
1121 let (targets, set_targets) = signal(Option::<Vec<PimCollectionInfo>>::None);
1122 let (preview, set_preview) = signal(Option::<Result<PimPreview, String>>::None);
1123 let (target, set_target) = signal(Option::<i64>::None);
1124 let (busy, set_busy) = signal(false);
1125 let (result, set_result) = signal(Option::<PimImportResult>::None);
1126 {
1127 let path = path.clone();
1128 spawn_local(async move {
1129 let tz = browser_tz();
1130 match api::pim_preview(root_id, &path, &tz).await {
1131 Ok(p) => set_preview.set(Some(Ok(p))),
1132 Err(e) => set_preview.set(Some(Err(e.to_string()))),
1133 }
1134 match api::pim_collections().await {
1135 Ok(list) => {
1136 let list: Vec<_> = list
1137 .into_iter()
1138 .filter(|c| c.kind == kind && writable(c))
1139 .collect();
1140 set_target.set(list.first().map(|c| c.id));
1141 set_targets.set(Some(list));
1142 }
1143 Err(e) => {
1144 show_error(toast, e.to_string());
1145 set_targets.set(Some(Vec::new()));
1146 }
1147 }
1148 });
1149 }
1150 let run = move |_| {
1151 let (Some(id), false) = (target.get(), busy.get()) else {
1152 return;
1153 };
1154 set_busy.set(true);
1155 let path = path.clone();
1156 spawn_local(async move {
1157 match api::pim_import(id, root_id, &path).await {
1158 Ok(r) => set_result.set(Some(r)),
1159 Err(e) => show_error(toast, e.to_string()),
1160 }
1161 set_busy.set(false);
1162 });
1163 };
1164 let summary = move |p: &PimPreview| {
1165 let mut parts = vec![match p.kind {
1166 PimCollectionKind::Calendar => i18n::t_fmt(k::PIM_FILE_EVENTS, &p.total.to_string()),
1167 PimCollectionKind::Addressbook => {
1168 i18n::t_fmt(k::PIM_FILE_CONTACTS, &p.total.to_string())
1169 }
1170 }];
1171 if p.invalid > 0 {
1172 parts.push(i18n::t_fmt(k::PIM_FILE_INVALID, &p.invalid.to_string()));
1173 }
1174 parts.join(" · ")
1175 };
1176 let sample = move |p: &PimPreview| {
1177 let names: Vec<String> = match p.kind {
1178 PimCollectionKind::Calendar => p
1179 .events
1180 .iter()
1181 .take(5)
1182 .map(|e| {
1183 let when = e.start.as_deref().map(|s| match e.all_day {
1184 true => s[..10.min(s.len())].to_string(),
1185 false => format_date(s),
1186 });
1187 let what = e.summary.clone().unwrap_or_else(|| "—".to_string());
1188 match when {
1189 Some(w) => format!("{w} · {what}"),
1190 None => what,
1191 }
1192 })
1193 .collect(),
1194 PimCollectionKind::Addressbook => p
1195 .contacts
1196 .iter()
1197 .take(5)
1198 .map(|c| match &c.email {
1199 Some(e) => format!("{} · {e}", c.full_name),
1200 None => c.full_name.clone(),
1201 })
1202 .collect(),
1203 };
1204 let shown = names.len();
1205 let rest = p.total.saturating_sub(p.invalid).saturating_sub(shown);
1206 view! {
1207 <ul class="pim-sample">
1208 {names.into_iter().map(|n| view! { <li>{n}</li> }).collect::<Vec<_>>()}
1209 {(rest > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &rest.to_string())}</li> })}
1210 </ul>
1211 }
1212 };
1213 view! {
1214 <Modal class="pim-modal" on_close=on_close>
1215 <h2 class="modal-title">{i18n::t_fmt(k::PIM_IMPORT_TITLE, &name)}</h2>
1216 {move || match result.get() {
1217 Some(r) => {
1218 let skipped = r.skipped.clone();
1219 let more = r.skipped_total.saturating_sub(skipped.len());
1220 view! {
1221 <p class="modal-message">
1222 {i18n::t_fmt2(k::PIM_IMPORT_DONE, &r.created.to_string(), &r.updated.to_string())}
1223 </p>
1224 {(r.skipped_total > 0).then(|| view! {
1225 <p class="modal-message">{i18n::t_fmt(k::PIM_IMPORT_SKIPPED, &r.skipped_total.to_string())}</p>
1226 <ul class="pim-sample">
1227 {skipped
1228 .into_iter()
1229 .map(|s| view! {
1230 <li>{format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), s.reason)}</li>
1231 })
1232 .collect::<Vec<_>>()}
1233 {(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
1234 </ul>
1235 })}
1236 <div class="modal-actions">
1237 <button class="btn btn-primary" on:click=move |_| on_close.run(())>{i18n::tr(k::DONE)}</button>
1238 </div>
1239 }
1240 .into_any()
1241 }
1242 None => view! {
1243 {move || match preview.get() {
1244 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
1245 Some(Err(e)) => view! { <p class="form-error">{e}</p> }.into_any(),
1246 Some(Ok(p)) => view! {
1247 <p class="modal-message">{summary(&p)}</p>
1248 {sample(&p)}
1249 }
1250 .into_any(),
1251 }}
1252 {move || match targets.get() {
1253 None => None,
1254 Some(list) if list.is_empty() => Some(view! {
1255 <p class="muted pim-hint">{i18n::tr(k::PIM_NO_TARGET)}</p>
1256 }.into_any()),
1257 Some(list) => Some(view! {
1258 <label class="field">
1259 <span class="field-label">{i18n::tr(k::PIM_IMPORT_INTO)}</span>
1260 <select
1261 class="field-input"
1262 on:change=move |ev| set_target.set(event_target_value(&ev).parse().ok())
1263 >
1264 {list
1265 .into_iter()
1266 .map(|c| {
1267 let label = match c.mode {
1268 Some(_) => format!("{} ({})", display_name(&c), c.owner),
1269 None => display_name(&c),
1270 };
1271 view! { <option value=c.id.to_string()>{label}</option> }
1272 })
1273 .collect::<Vec<_>>()}
1274 </select>
1275 </label>
1276 }.into_any()),
1277 }}
1278 <div class="modal-actions">
1279 <button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CANCEL)}</button>
1280 <button
1281 class="btn btn-primary"
1282 disabled=move || {
1283 busy.get()
1284 || target.get().is_none()
1285 || !matches!(preview.get(), Some(Ok(ref p)) if p.total > p.invalid)
1286 }
1287 on:click=run.clone()
1288 >
1289 {move || if busy.get() { i18n::t(k::PIM_IMPORTING) } else { i18n::t(k::PIM_IMPORT_BTN) }}
1290 </button>
1291 </div>
1292 }
1293 .into_any(),
1294 }}
1295 </Modal>
1296 }
1297}
1298