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