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