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