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