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