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