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