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