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