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