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_futures::spawn_local;
10
11use crate::api::{
12 self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimShareInfo,
13 PimShareMode,
14};
15use crate::components::icon::{Icon, icon_svg};
16use crate::components::modal::Modal;
17use crate::components::toast::{ToastMsg, show, show_error};
18use crate::i18n::{self, k};
19use crate::icons::IconName;
20use crate::router::{Location, Section, navigate, server_url};
21use crate::util::{copy_to_clipboard, local_date_time, storage_get, storage_set};
22use crate::views::calendar::CalendarMain;
23use crate::views::contacts::ContactsMain;
24use crate::views::dialogs::ConfirmDialog;
25use crate::views::shares::expiry_to_rfc3339;
26
27/// Colors offered for a collection; a custom one comes from the color input.
28const PALETTE: [&str; 8] = [
29 "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b",
30];
31
32/// The collections the lists do not show, by id.
33const HIDDEN_KEY: &str = "dovenest.pim-hidden";
34
35/// Collection ids hidden in the calendar and contact views.
36pub fn load_hidden() -> HashSet<i64> {
37 storage_get(HIDDEN_KEY)
38 .unwrap_or_default()
39 .split(',')
40 .filter_map(|s| s.parse().ok())
41 .collect()
42}
43
44fn save_hidden(ids: &HashSet<i64>) {
45 let list: Vec<String> = ids.iter().map(i64::to_string).collect();
46 storage_set(HIDDEN_KEY, &list.join(","));
47}
48
49/// A collection's color for a style attribute. Colors of lent collections
50/// come from other clients, so anything but a hex color falls back.
51pub fn safe_color(c: Option<&str>) -> String {
52 match c.and_then(|c| c.strip_prefix('#')) {
53 Some(h) if [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()) => {
54 format!("#{h}")
55 }
56 _ => "var(--accent)".to_string(),
57 }
58}
59
60/// The name to show: generated collections have translated names.
61pub fn display_name(c: &PimCollectionInfo) -> String {
62 match (c.generated, c.kind) {
63 (true, PimCollectionKind::Calendar) => i18n::t(k::PIM_BIRTHDAYS).to_string(),
64 (true, PimCollectionKind::Addressbook) => i18n::t(k::PIM_DIRECTORY).to_string(),
65 _ => c.name.clone(),
66 }
67}
68
69/// Whether the signed-in user may add objects to a collection.
70pub fn writable(c: &PimCollectionInfo) -> bool {
71 !c.generated && c.mode != Some(PimShareMode::Ro)
72}
73
74fn mode_label(m: PimShareMode) -> &'static str {
75 match m {
76 PimShareMode::Ro => i18n::t(k::PIM_MODE_RO),
77 PimShareMode::Rw => i18n::t(k::PIM_MODE_RW),
78 PimShareMode::RwSchedule => i18n::t(k::PIM_MODE_SCHEDULE),
79 }
80}
81
82/// The browser's IANA time zone, for dates the server resolves.
83pub fn browser_tz() -> String {
84 let opts = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new())
85 .resolved_options();
86 js_sys::Reflect::get(&opts, &"timeZone".into())
87 .ok()
88 .and_then(|v| v.as_string())
89 .unwrap_or_else(|| "UTC".to_string())
90}
91
92/// The calendar or contacts section: the collection list, and the view of
93/// what the visible collections hold.
94/// A readable form of the precondition an import skip names.
95fn skip_reason(reason: &str) -> String {
96 let key = match reason {
97 "valid-calendar-data" | "valid-address-data" | "valid-calendar-object-resource" => {
98 k::PIM_SKIP_INVALID
99 }
100 "supported-calendar-component" => k::PIM_SKIP_COMPONENT,
101 "unique-scheduling-object-resource" => k::PIM_SKIP_UID,
102 "max-resource-size" => k::PIM_SKIP_SIZE,
103 _ => return reason.to_string(),
104 };
105 i18n::t(key).to_string()
106}
107
108#[component]
109pub fn PimView(
110 kind: PimCollectionKind,
111 me: ReadSignal<Option<Me>>,
112 loc: ReadSignal<Location>,
113) -> impl IntoView {
114 let (collections, set_collections) = signal(Option::<Vec<PimCollectionInfo>>::None);
115 let (load_err, set_load_err) = signal(Option::<String>::None);
116 let hidden = RwSignal::new(load_hidden());
117 let reload = Callback::new(move |_| {
118 set_load_err.set(None);
119 spawn_local(async move {
120 match api::pim_collections().await {
121 Ok(v) => set_collections.set(Some(v)),
122 Err(e) => set_load_err.set(Some(e.to_string())),
123 }
124 });
125 });
126 reload.run(());
127 let main = match kind {
128 PimCollectionKind::Calendar => {
129 let week_start = Signal::derive(move || {
130 me.with(|m| {
131 m.as_ref()
132 .and_then(|m| m.user.as_ref())
133 .map_or(1, |u| u32::from(u.week_start))
134 })
135 });
136 view! {
137 <CalendarMain collections=collections hidden=hidden loc=loc week_start=week_start/>
138 }
139 .into_any()
140 }
141 PimCollectionKind::Addressbook => {
142 view! { <ContactsMain collections=collections hidden=hidden loc=loc/> }.into_any()
143 }
144 };
145 view! {
146 <div class="pim-view">
147 <CollectionList
148 kind=kind
149 collections=collections
150 load_err=load_err
151 hidden=hidden
152 reload=reload
153 me=me
154 />
155 <section class="pim-main">{main}</section>
156 </div>
157 }
158}
159
160#[component]
161fn CollectionList(
162 kind: PimCollectionKind,
163 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
164 load_err: ReadSignal<Option<String>>,
165 hidden: RwSignal<HashSet<i64>>,
166 reload: Callback<()>,
167 me: ReadSignal<Option<Me>>,
168) -> impl IntoView {
169 let (managing, set_managing) = signal(Option::<PimCollectionInfo>::None);
170 let (creating, set_creating) = signal(false);
171 let (connecting, set_connecting) = signal(false);
172 let calendars = kind == PimCollectionKind::Calendar;
173 let (title, new_label) = match calendars {
174 true => (k::PIM_CALENDARS, k::PIM_NEW_CALENDAR),
175 false => (k::PIM_ADDRESS_BOOKS, k::PIM_NEW_ADDRESS_BOOK),
176 };
177 let row = move |c: PimCollectionInfo| {
178 let id = c.id;
179 let is_hidden = move || hidden.with(|h| h.contains(&id));
180 let toggle = move |_| {
181 hidden.update(|h| {
182 if !h.remove(&id) {
183 h.insert(id);
184 }
185 save_hidden(h);
186 });
187 };
188 let note = match (c.generated, c.mode) {
189 (true, _) => Some(i18n::t(k::PIM_AUTO).to_string()),
190 (false, Some(_)) => Some(c.owner.clone()),
191 _ => None,
192 };
193 let name = display_name(&c);
194 let color = safe_color(c.color.as_deref());
195 let info = c.clone();
196 view! {
197 <div class="pim-row" class:pim-row-hidden=is_hidden>
198 <button
199 class="pim-toggle"
200 title=move || if is_hidden() { i18n::t(k::PIM_SHOW) } else { i18n::t(k::PIM_HIDE) }
201 // Pressed means shown. A label that flips with the state
202 // would read "Hide, pressed" to a screen reader.
203 aria-label=name.clone()
204 aria-pressed=move || (!is_hidden()).to_string()
205 on:click=toggle
206 >
207 <span class="pim-swatch" style=format!("--swatch: {color}")></span>
208 </button>
209 <button
210 class="pim-row-main"
211 title=name.clone()
212 on:click=move |_| set_managing.set(Some(info.clone()))
213 >
214 <span class="pim-row-name">{name.clone()}</span>
215 {note.map(|n| view! { <span class="pim-row-note">{n}</span> })}
216 </button>
217 </div>
218 }
219 };
220 view! {
221 <aside class="pim-side">
222 <div class="pim-side-head">
223 <h3 class="admin-section-title">{i18n::tr(title)}</h3>
224 <button
225 class="icon-btn icon-btn-sm"
226 title=i18n::tr(k::PIM_CONNECT)
227 aria-label=i18n::tr(k::PIM_CONNECT)
228 on:click=move |_| set_connecting.set(true)
229 >
230 <Icon name=IconName::Devices class="ic-btn".to_string()/>
231 </button>
232 <button
233 class="icon-btn icon-btn-sm"
234 title=i18n::tr(new_label)
235 aria-label=i18n::tr(new_label)
236 on:click=move |_| set_creating.set(true)
237 >
238 <Icon name=IconName::Add class="ic-btn".to_string()/>
239 </button>
240 </div>
241 {move || match collections.get() {
242 None => match load_err.get() {
243 Some(msg) => view! {
244 <div class="card error-card">
245 <span>{msg}</span>
246 <button class="btn" on:click=move |_| reload.run(())>
247 {i18n::tr(k::RETRY)}
248 </button>
249 </div>
250 }
251 .into_any(),
252 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
253 },
254 Some(list) => {
255 let (mine, lent): (Vec<_>, Vec<_>) = list
256 .into_iter()
257 .filter(|c| c.kind == kind)
258 .partition(|c| c.mode.is_none());
259 let lent_block = (!lent.is_empty()).then(|| view! {
260 <div class="pim-group-label">{i18n::tr(k::PIM_SHARED_WITH_ME)}</div>
261 {lent.into_iter().map(row).collect::<Vec<_>>()}
262 });
263 view! {
264 <div class="pim-list">
265 {mine.into_iter().map(row).collect::<Vec<_>>()}
266 {lent_block}
267 </div>
268 }
269 .into_any()
270 }
271 }}
272 </aside>
273 {move || managing.get().map(|info| view! {
274 <CollectionDialog
275 info=info
276 on_close=Callback::new(move |_| set_managing.set(None))
277 on_changed=reload
278 />
279 })}
280 <Show when=move || creating.get()>
281 <NewCollectionDialog
282 kind=kind
283 on_close=Callback::new(move |_| set_creating.set(false))
284 on_created=reload
285 />
286 </Show>
287 <Show when=move || connecting.get()>
288 <ConnectDialog
289 me=me
290 collections=collections
291 on_close=Callback::new(move |_| set_connecting.set(false))
292 />
293 </Show>
294 }
295}
296
297/// The palette plus a custom color. `None` clears the color.
298#[component]
299fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
300 view! {
301 <div class="pim-colors" role="radiogroup" aria-label=i18n::tr(k::PIM_COLOR)>
302 {PALETTE
303 .iter()
304 .map(|&c| {
305 view! {
306 <button
307 type="button"
308 class="pim-color"
309 class:selected=move || color.with(|v| v.as_deref() == Some(c))
310 role="radio"
311 aria-checked=move || color.with(|v| v.as_deref() == Some(c)).to_string()
312 title=c
313 style=format!("--swatch: {c}")
314 on:click=move |_| color.set(Some(c.to_string()))
315 ></button>
316 }
317 })
318 .collect::<Vec<_>>()}
319 <input
320 type="color"
321 class="pim-color-input"
322 title=i18n::tr(k::PIM_CUSTOM_COLOR)
323 aria-label=i18n::tr(k::PIM_CUSTOM_COLOR)
324 prop:value=move || {
325 color.with(|v| {
326 v.as_deref()
327 .filter(|c| c.len() >= 7)
328 .map(|c| c[..7].to_string())
329 .unwrap_or_else(|| "#3b82f6".to_string())
330 })
331 }
332 on:input=move |ev| color.set(Some(event_target_value(&ev)))
333 />
334 </div>
335 }
336}
337
338#[component]
339fn NewCollectionDialog(
340 kind: PimCollectionKind,
341 on_close: Callback<()>,
342 on_created: Callback<()>,
343) -> impl IntoView {
344 let toast = use_context::<ToastMsg>().expect("toast context");
345 let (name, set_name) = signal(String::new());
346 let color = RwSignal::new(Some(PALETTE[0].to_string()));
347 let (description, set_description) = signal(String::new());
348 let (busy, set_busy) = signal(false);
349 let title = match kind {
350 PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR,
351 PimCollectionKind::Addressbook => k::PIM_NEW_ADDRESS_BOOK,
352 };
353 let submit = move |ev: web_sys::SubmitEvent| {
354 ev.prevent_default();
355 let n = name.get().trim().to_string();
356 if n.is_empty() || busy.get() {
357 return;
358 }
359 set_busy.set(true);
360 let body = CreatePimCollection {
361 kind,
362 name: n,
363 color: color.get(),
364 description: Some(description.get()).filter(|d| !d.trim().is_empty()),
365 components: Vec::new(),
366 };
367 spawn_local(async move {
368 match api::pim_create_collection(body).await {
369 Ok(_) => {
370 show(toast, i18n::t(k::PIM_CREATED));
371 on_created.run(());
372 on_close.run(());
373 }
374 Err(e) => {
375 show_error(toast, e.to_string());
376 set_busy.set(false);
377 }
378 }
379 });
380 };
381 view! {
382 <Modal class="pim-modal" on_close=on_close>
383 <form on:submit=submit>
384 <h2 class="modal-title">{i18n::tr(title)}</h2>
385 <label class="field">
386 <span class="field-label">{i18n::tr(k::NAME)}</span>
387 <input
388 class="field-input"
389 required=true
390 maxlength="200"
391 autofocus=true
392 prop:value=move || name.get()
393 on:input=move |ev| set_name.set(event_target_value(&ev))
394 />
395 </label>
396 <div class="field">
397 <span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
398 <ColorPicker color=color/>
399 </div>
400 <label class="field">
401 <span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
402 <textarea
403 class="field-input"
404 rows="2"
405 prop:value=move || description.get()
406 on:input=move |ev| set_description.set(event_target_value(&ev))
407 ></textarea>
408 </label>
409 <div class="modal-actions">
410 <button type="button" class="btn" on:click=move |_| on_close.run(())>
411 {i18n::tr(k::CANCEL)}
412 </button>
413 <button type="submit" class="btn btn-primary" disabled=move || busy.get()>
414 {i18n::tr(k::CREATE)}
415 </button>
416 </div>
417 </form>
418 </Modal>
419 }
420}
421
422/// A read-only URL with a copy button.
423fn url_row(url: String, toast: ToastMsg) -> impl IntoView {
424 let copy = url.clone();
425 view! {
426 <div class="share-link-row">
427 <input
428 class="share-link-input"
429 readonly=true
430 value=url
431 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
432 />
433 <button
434 type="button"
435 class="btn"
436 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
437 >
438 {icon_svg(IconName::Copy, "ic-btn")}
439 {i18n::t(k::COPY)}
440 </button>
441 </div>
442 }
443}
444
445/// Everything about one collection: its settings, who it is lent to, its
446/// public links, export and deletion.
447#[component]
448fn CollectionDialog(
449 info: PimCollectionInfo,
450 on_close: Callback<()>,
451 on_changed: Callback<()>,
452) -> impl IntoView {
453 let toast = use_context::<ToastMsg>().expect("toast context");
454 let own = info.mode.is_none() && !info.generated;
455 let url = server_url(&info.url);
456 let lent_note = info.mode.map(|m| {
457 format!(
458 "{} · {}",
459 i18n::t_fmt(k::PIM_SHARED_BY, &info.owner),
460 mode_label(m)
461 )
462 });
463 let generated_note = info.generated.then(|| i18n::t(k::PIM_GENERATED_HINT));
464 view! {
465 <Modal class="pim-modal" on_close=on_close>
466 <h2 class="modal-title pim-modal-title">
467 <span class="pim-swatch" style=format!("--swatch: {}", safe_color(info.color.as_deref()))></span>
468 <span>{display_name(&info)}</span>
469 </h2>
470 {lent_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
471 {generated_note.map(|n| view! { <p class="muted pim-note">{n}</p> })}
472 {own.then(|| view! { <GeneralSection info=info.clone() on_changed=on_changed/> })}
473 <section class="pim-section">
474 <h3 class="pim-section-title">{i18n::tr(k::PIM_ADDRESS)}</h3>
475 {url_row(url, toast)}
476 </section>
477 {own.then(|| view! { <SharingSection info=info.clone()/> })}
478 {own.then(|| view! { <LinksSection info=info.clone()/> })}
479 {writable(&info).then(|| view! { <ImportSection info=info.clone() on_changed=on_changed/> })}
480 <ExportSection info=info.clone()/>
481 {(!info.generated).then(|| view! {
482 <DeleteSection info=info.clone() on_close=on_close on_changed=on_changed/>
483 })}
484 <div class="modal-actions">
485 <button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
486 </div>
487 </Modal>
488 }
489}
490
491#[component]
492fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
493 let toast = use_context::<ToastMsg>().expect("toast context");
494 let id = info.id;
495 let calendar = info.kind == PimCollectionKind::Calendar;
496 let (name, set_name) = signal(info.name.clone());
497 let color = RwSignal::new(info.color.clone());
498 let (description, set_description) = signal(info.description.clone().unwrap_or_default());
499 let (transparent, set_transparent) = signal(info.transparent);
500 let (busy, set_busy) = signal(false);
501 let save = move |ev: web_sys::SubmitEvent| {
502 ev.prevent_default();
503 if busy.get() || name.get().trim().is_empty() {
504 return;
505 }
506 set_busy.set(true);
507 let body = UpdatePimCollection {
508 name: Some(name.get()),
509 color: Some(color.get().unwrap_or_default()),
510 description: Some(description.get()),
511 transparent: calendar.then(|| transparent.get()),
512 };
513 spawn_local(async move {
514 match api::pim_update_collection(id, body).await {
515 Ok(_) => {
516 show(toast, i18n::t(k::SAVED));
517 on_changed.run(());
518 }
519 Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())),
520 }
521 set_busy.set(false);
522 });
523 };
524 view! {
525 <form class="pim-section" on:submit=save>
526 <h3 class="pim-section-title">{i18n::tr(k::PIM_GENERAL)}</h3>
527 <label class="field">
528 <span class="field-label">{i18n::tr(k::NAME)}</span>
529 <input
530 class="field-input"
531 required=true
532 maxlength="200"
533 prop:value=move || name.get()
534 on:input=move |ev| set_name.set(event_target_value(&ev))
535 />
536 </label>
537 <div class="field">
538 <span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
539 <ColorPicker color=color/>
540 </div>
541 <label class="field">
542 <span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
543 <textarea
544 class="field-input"
545 rows="2"
546 prop:value=move || description.get()
547 on:input=move |ev| set_description.set(event_target_value(&ev))
548 ></textarea>
549 </label>
550 {calendar.then(|| view! {
551 <label class="check-row">
552 <input
553 type="checkbox"
554 prop:checked=move || transparent.get()
555 on:change=move |ev| set_transparent.set(event_target_checked(&ev))
556 />
557 {i18n::tr(k::PIM_TRANSPARENT)}
558 </label>
559 })}
560 <div class="pim-section-actions">
561 <button type="submit" class="btn btn-primary btn-sm" disabled=move || busy.get()>
562 {move || if busy.get() { i18n::t(k::SAVING) } else { i18n::t(k::SAVE) }}
563 </button>
564 </div>
565 </form>
566 }
567}
568
569#[component]
570fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
571 let toast = use_context::<ToastMsg>().expect("toast context");
572 let id = info.id;
573 let calendar = info.kind == PimCollectionKind::Calendar;
574 let modes: Vec<PimShareMode> = match calendar {
575 true => vec![PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule],
576 false => vec![PimShareMode::Ro, PimShareMode::Rw],
577 };
578 let (shares, set_shares) = signal(Option::<Vec<PimShareInfo>>::None);
579 let (user, set_user) = signal(String::new());
580 let (mode, set_mode) = signal(PimShareMode::Ro);
581 let load = move || {
582 spawn_local(async move {
583 match api::pim_shares(id).await {
584 Ok(v) => set_shares.set(Some(v)),
585 Err(e) => show_error(toast, e.to_string()),
586 }
587 });
588 };
589 load();
590 let set = move |who: String, m: PimShareMode| {
591 spawn_local(async move {
592 match api::pim_share(id, &who, m).await {
593 Ok(s) => {
594 show(toast, i18n::t_fmt(k::PIM_SHARED_OK, &s.user_name));
595 set_user.set(String::new());
596 load();
597 }
598 Err(e) => show_error(toast, e.to_string()),
599 }
600 });
601 };
602 let add = move |ev: web_sys::SubmitEvent| {
603 ev.prevent_default();
604 let who = user.get().trim().to_string();
605 if !who.is_empty() {
606 set(who, mode.get());
607 }
608 };
609 let mode_options = move |current: PimShareMode| {
610 modes
611 .iter()
612 .map(|&m| {
613 view! {
614 <option value=m.as_str() selected=m == current>{mode_label(m)}</option>
615 }
616 })
617 .collect::<Vec<_>>()
618 };
619 let mode_options_add = mode_options.clone();
620 view! {
621 <section class="pim-section">
622 <h3 class="pim-section-title">{i18n::tr(k::PIM_SHARING)}</h3>
623 <p class="muted pim-hint">{i18n::tr(k::PIM_SHARING_HINT)}</p>
624 {move || match shares.get() {
625 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
626 Some(list) if list.is_empty() => {
627 view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_SHARES)}</p> }.into_any()
628 }
629 Some(list) => {
630 let options = mode_options.clone();
631 view! {
632 <div class="pim-items">
633 {list
634 .into_iter()
635 .map(|s| {
636 let (uid, who) = (s.user_id, s.user_name.clone());
637 view! {
638 <div class="pim-item">
639 <span class="pim-item-name">{s.user_name.clone()}</span>
640 <select
641 class="pim-mode"
642 aria-label=i18n::tr(k::PIM_ACCESS)
643 on:change=move |ev| {
644 if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
645 set(who.clone(), m);
646 }
647 }
648 >
649 {options(s.mode)}
650 </select>
651 <button
652 type="button"
653 class="icon-btn icon-btn-sm"
654 title=i18n::tr(k::PIM_UNSHARE)
655 aria-label=i18n::tr(k::PIM_UNSHARE)
656 on:click=move |_| {
657 spawn_local(async move {
658 match api::pim_unshare(id, uid).await {
659 Ok(_) => {
660 show(toast, i18n::t(k::PIM_UNSHARED));
661 load();
662 }
663 Err(e) => show_error(toast, e.to_string()),
664 }
665 });
666 }
667 >
668 <Icon name=IconName::Close class="ic-btn".to_string()/>
669 </button>
670 </div>
671 }
672 })
673 .collect::<Vec<_>>()}
674 </div>
675 }
676 .into_any()
677 }
678 }}
679 <form class="pim-add" on:submit=add>
680 <input
681 class="field-input"
682 required=true
683 placeholder=i18n::tr(k::PIM_USER_NAME)
684 aria-label=i18n::tr(k::PIM_USER_NAME)
685 autocomplete="off"
686 prop:value=move || user.get()
687 on:input=move |ev| set_user.set(event_target_value(&ev))
688 />
689 <select
690 class="pim-mode"
691 aria-label=i18n::tr(k::PIM_ACCESS)
692 on:change=move |ev| {
693 if let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) {
694 set_mode.set(m);
695 }
696 }
697 >
698 {mode_options_add(PimShareMode::Ro)}
699 </select>
700 <button type="submit" class="btn btn-sm">{i18n::tr(k::PIM_ADD)}</button>
701 </form>
702 </section>
703 }
704}
705
706#[component]
707fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
708 let toast = use_context::<ToastMsg>().expect("toast context");
709 let id = info.id;
710 let calendar = info.kind == PimCollectionKind::Calendar;
711 let (links, set_links) = signal(Option::<Vec<PimLinkInfo>>::None);
712 let (busy_only, set_busy_only) = signal(false);
713 let (choice, set_choice) = signal("never".to_string());
714 let (custom, set_custom) = signal(String::new());
715 let (password, set_password) = signal(String::new());
716 let (busy, set_busy) = signal(false);
717 let load = move || {
718 spawn_local(async move {
719 match api::pim_links(id).await {
720 Ok(v) => set_links.set(Some(v)),
721 Err(e) => show_error(toast, e.to_string()),
722 }
723 });
724 };
725 load();
726 let create = move |ev: web_sys::SubmitEvent| {
727 ev.prevent_default();
728 if busy.get() {
729 return;
730 }
731 set_busy.set(true);
732 let pw = password.get().trim().to_string();
733 let body = CreatePimLink {
734 busy_only: busy_only.get(),
735 expires_at: expiry_to_rfc3339(&choice.get(), &custom.get()),
736 password: (!pw.is_empty()).then_some(pw),
737 };
738 spawn_local(async move {
739 match api::pim_create_link(id, body).await {
740 Ok(_) => {
741 set_password.set(String::new());
742 load();
743 }
744 Err(e) => show_error(toast, e.to_string()),
745 }
746 set_busy.set(false);
747 });
748 };
749 let link_row = move |l: PimLinkInfo| {
750 let full = server_url(&l.path);
751 // `webcal://` opens the calendar app; the transport stays HTTP(S).
752 let webcal = full
753 .strip_prefix("https://")
754 .or_else(|| full.strip_prefix("http://"))
755 .map(|rest| format!("webcal://{rest}"));
756 let mut meta = Vec::new();
757 if calendar {
758 meta.push(if l.busy_only {
759 i18n::t(k::PIM_LINK_BUSY).to_string()
760 } else {
761 i18n::t(k::PIM_LINK_FULL).to_string()
762 });
763 }
764 if let Some(e) = &l.expires_at {
765 meta.push(i18n::t_fmt(k::META_EXPIRES, &local_date_time(e)));
766 }
767 if l.has_password {
768 meta.push(i18n::t(k::SHARE_PROTECTED).to_string());
769 }
770 let link_id = l.id;
771 let copy = full.clone();
772 view! {
773 <div class="pim-link">
774 <div class="pim-link-main">
775 <span class="pim-link-url" title=full.clone()>{full.clone()}</span>
776 <span class="muted pim-link-meta">{meta.join(" · ")}</span>
777 </div>
778 <div class="pim-link-actions">
779 <button
780 type="button"
781 class="btn btn-sm"
782 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
783 >
784 {icon_svg(IconName::Copy, "ic-btn")}
785 {i18n::t(k::COPY)}
786 </button>
787 {calendar.then(|| webcal.map(|w| view! {
788 <a class="btn btn-sm" href=w>{i18n::t(k::PIM_SUBSCRIBE)}</a>
789 }))}
790 <button
791 type="button"
792 class="btn btn-sm btn-danger"
793 on:click=move |_| {
794 spawn_local(async move {
795 match api::pim_delete_link(id, link_id).await {
796 Ok(_) => {
797 show(toast, i18n::t(k::PIM_LINK_REVOKED));
798 load();
799 }
800 Err(e) => show_error(toast, e.to_string()),
801 }
802 });
803 }
804 >
805 {i18n::t(k::DELETE)}
806 </button>
807 </div>
808 </div>
809 }
810 };
811 view! {
812 <section class="pim-section">
813 <h3 class="pim-section-title">{i18n::tr(k::PIM_LINKS)}</h3>
814 <p class="muted pim-hint">
815 {move || match calendar {
816 true => i18n::t(k::PIM_LINKS_HINT),
817 false => i18n::t(k::PIM_LINKS_HINT_CARDS),
818 }}
819 </p>
820 {move || match links.get() {
821 None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
822 Some(list) if list.is_empty() => {
823 view! { <p class="muted pim-hint">{i18n::tr(k::PIM_NO_LINKS)}</p> }.into_any()
824 }
825 Some(list) => view! {
826 <div class="pim-items">{list.into_iter().map(link_row).collect::<Vec<_>>()}</div>
827 }
828 .into_any(),
829 }}
830 <form class="pim-link-form" on:submit=create>
831 {calendar.then(|| view! {
832 <label class="pim-inline">
833 <span>{i18n::tr(k::PIM_DETAIL)}</span>
834 <select on:change=move |ev| set_busy_only.set(event_target_value(&ev) == "busy")>
835 <option value="full">{i18n::t(k::PIM_DETAIL_FULL)}</option>
836 <option value="busy">{i18n::t(k::PIM_DETAIL_BUSY)}</option>
837 </select>
838 </label>
839 })}
840 <label class="pim-inline">
841 <span>{i18n::tr(k::EXPIRES)}</span>
842 <select on:change=move |ev| set_choice.set(event_target_value(&ev))>
843 <option value="never">{i18n::t(k::NEVER)}</option>
844 <option value="1d">{i18n::t(k::EXPIRY_1D)}</option>
845 <option value="1w">{i18n::t(k::EXPIRY_1W)}</option>
846 <option value="1mo">{i18n::t(k::EXPIRY_1MO)}</option>
847 <option value="custom">{i18n::t(k::CUSTOM)}</option>
848 </select>
849 </label>
850 <Show when=move || choice.get() == "custom">
851 <label class="pim-inline">
852 <span>{i18n::tr(k::UNTIL)}</span>
853 <input
854 type="datetime-local"
855 prop:value=move || custom.get()
856 on:change=move |ev| set_custom.set(event_target_value(&ev))
857 />
858 </label>
859 </Show>
860 <label class="pim-inline">
861 <span>{i18n::tr(k::PASSWORD)}</span>
862 <input
863 type="password"
864 autocomplete="new-password"
865 prop:value=move || password.get()
866 on:input=move |ev| set_password.set(event_target_value(&ev))
867 />
868 </label>
869 <div class="pim-section-actions">
870 <button type="submit" class="btn btn-sm" disabled=move || busy.get()>
871 {i18n::tr(k::CREATE_LINK)}
872 </button>
873 </div>
874 </form>
875 </section>
876 }
877}
878
879/// A file name for an export: the collection name without path separators.
880fn export_file(info: &PimCollectionInfo) -> String {
881 let ext = match info.kind {
882 PimCollectionKind::Calendar => "ics",
883 PimCollectionKind::Addressbook => "vcf",
884 };
885 let base: String = display_name(info)
886 .chars()
887 .map(|c| {
888 if matches!(c, '/' | '\\' | ':') {
889 '_'
890 } else {
891 c
892 }
893 })
894 .collect();
895 format!("{}.{ext}", base.trim())
896}
897
898#[component]
899fn ExportSection(info: PimCollectionInfo) -> impl IntoView {
900 let id = info.id;
901 let file = export_file(&info);
902 view! {
903 <section class="pim-section">
904 <h3 class="pim-section-title">{i18n::tr(k::PIM_EXPORT)}</h3>
905 <div class="pim-section-actions pim-left">
906 <button
907 type="button"
908 class="btn btn-sm"
909 on:click=move |_| api::trigger_download(&api::pim_export_url(id), &file)
910 >
911 {icon_svg(IconName::Download, "ic-btn")}
912 {i18n::t(k::DOWNLOAD)}
913 </button>
914 </div>
915 </section>
916 }
917}
918
919/// Upload an `.ics` or `.vcf` file into the collection.
920#[component]
921fn ImportSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView {
922 let toast = use_context::<ToastMsg>().expect("toast context");
923 let id = info.id;
924 let input: NodeRef<leptos::html::Input> = NodeRef::new();
925 let (busy, set_busy) = signal(false);
926 let (result, set_result) = signal(Option::<PimImportResult>::None);
927 let picked = move |_| {
928 let Some(file) = input
929 .get_untracked()
930 .and_then(|el| el.files())
931 .and_then(|list| list.get(0))
932 else {
933 return;
934 };
935 set_busy.set(true);
936 set_result.set(None);
937 spawn_local(async move {
938 match api::pim_import(id, file).await {
939 Ok(r) => {
940 set_result.set(Some(r));
941 on_changed.run(());
942 }
943 Err(e) => show_error(toast, e.to_string()),
944 }
945 set_busy.set(false);
946 // The same file again must fire `change` again.
947 if let Some(el) = input.get_untracked() {
948 el.set_value("");
949 }
950 });
951 };
952 view! {
953 <section class="pim-section">
954 <h3 class="pim-section-title">{i18n::tr(k::PIM_IMPORT)}</h3>
955 <p class="muted pim-hint">{i18n::tr(match info.kind {
956 PimCollectionKind::Calendar => k::PIM_IMPORT_HINT_CAL,
957 PimCollectionKind::Addressbook => k::PIM_IMPORT_HINT_CARDS,
958 })}</p>
959 <input
960 node_ref=input
961 type="file"
962 class="pim-file-input"
963 accept=".ics,.vcf,text/calendar,text/vcard"
964 on:change=picked
965 />
966 <div class="pim-section-actions pim-left">
967 <button
968 type="button"
969 class="btn btn-sm"
970 disabled=move || busy.get()
971 on:click=move |_| {
972 if let Some(el) = input.get_untracked() {
973 el.click();
974 }
975 }
976 >
977 {icon_svg(IconName::Upload, "ic-btn")}
978 {move || if busy.get() { i18n::t(k::PIM_IMPORTING) } else { i18n::t(k::PIM_IMPORT_FILE) }}
979 </button>
980 </div>
981 {move || result.get().map(|r| {
982 let more = r.skipped_total.saturating_sub(r.skipped.len());
983 view! {
984 <p class="pim-hint">
985 {i18n::t_fmt2(k::PIM_IMPORT_DONE, &r.created.to_string(), &r.updated.to_string())}
986 </p>
987 {(r.skipped_total > 0).then(|| view! {
988 <p class="pim-hint">{i18n::t_fmt(k::PIM_IMPORT_SKIPPED, &r.skipped_total.to_string())}</p>
989 <ul class="pim-sample">
990 {r.skipped
991 .into_iter()
992 .map(|s| view! {
993 <li>{format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), skip_reason(&s.reason))}</li>
994 })
995 .collect::<Vec<_>>()}
996 {(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
997 </ul>
998 })}
999 }
1000 })}
1001 </section>
1002 }
1003}
1004
1005#[component]
1006fn DeleteSection(
1007 info: PimCollectionInfo,
1008 on_close: Callback<()>,
1009 on_changed: Callback<()>,
1010) -> impl IntoView {
1011 let toast = use_context::<ToastMsg>().expect("toast context");
1012 let (confirm, set_confirm) = signal(false);
1013 let id = info.id;
1014 let lent = info.mode.is_some();
1015 let name = display_name(&info);
1016 let run = Callback::new(move |_| {
1017 set_confirm.set(false);
1018 spawn_local(async move {
1019 match api::pim_delete_collection(id).await {
1020 Ok(_) => {
1021 show(
1022 toast,
1023 match lent {
1024 true => i18n::t(k::PIM_REMOVED),
1025 false => i18n::t(k::DELETED),
1026 },
1027 );
1028 on_changed.run(());
1029 on_close.run(());
1030 }
1031 Err(e) => show_error(toast, e.to_string()),
1032 }
1033 });
1034 });
1035 view! {
1036 <section class="pim-section">
1037 {match (lent, info.is_default) {
1038 (_, true) => view! { <p class="muted pim-hint">{i18n::tr(k::PIM_DEFAULT_HINT)}</p> }.into_any(),
1039 (true, _) => view! {
1040 <button type="button" class="btn btn-sm" on:click=move |_| set_confirm.set(true)>
1041 {i18n::tr(k::PIM_REMOVE_FROM_LIST)}
1042 </button>
1043 }
1044 .into_any(),
1045 (false, _) => view! {
1046 <button type="button" class="btn btn-sm btn-danger" on:click=move |_| set_confirm.set(true)>
1047 {icon_svg(IconName::Delete, "ic-btn")}
1048 {i18n::t(k::DELETE)}
1049 </button>
1050 }
1051 .into_any(),
1052 }}
1053 </section>
1054 <Show when=move || confirm.get()>
1055 <ConfirmDialog
1056 title=i18n::t_fmt(k::PIM_DELETE_TITLE, &name)
1057 message=match lent {
1058 true => i18n::t(k::PIM_REMOVE_MSG).to_string(),
1059 false => i18n::t(k::PIM_DELETE_MSG).to_string(),
1060 }
1061 submit=match lent {
1062 true => i18n::t(k::PIM_REMOVE_FROM_LIST).to_string(),
1063 false => i18n::t(k::DELETE).to_string(),
1064 }
1065 danger=!lent
1066 on_submit=run
1067 on_close=Callback::new(move |_| set_confirm.set(false))
1068 />
1069 </Show>
1070 }
1071}
1072
1073/// How to set up a calendar or contacts app against this server.
1074#[component]
1075fn ConnectDialog(
1076 me: ReadSignal<Option<Me>>,
1077 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1078 on_close: Callback<()>,
1079) -> impl IntoView {
1080 let toast = use_context::<ToastMsg>().expect("toast context");
1081 let user = me.with_untracked(|m| {
1082 m.as_ref()
1083 .and_then(|m| m.user.as_ref())
1084 .map(|u| u.name.clone())
1085 .unwrap_or_default()
1086 });
1087 let app_passwords = move |_| {
1088 on_close.run(());
1089 navigate(&Location {
1090 section: Section::Settings,
1091 path: vec!["security".to_string()],
1092 ..Location::root()
1093 });
1094 };
1095 view! {
1096 <Modal class="pim-modal" on_close=on_close>
1097 <h2 class="modal-title">{i18n::tr(k::PIM_CONNECT_TITLE)}</h2>
1098 <section class="pim-section">
1099 <h3 class="pim-section-title">{i18n::tr(k::PIM_SERVER_ADDRESS)}</h3>
1100 {url_row(server_url("/"), toast)}
1101 <p class="muted pim-hint">{i18n::tr(k::PIM_SERVER_ADDRESS_HINT)}</p>
1102 {url_row(server_url(&format!("{}/", api_types::PIM)), toast)}
1103 </section>
1104 <section class="pim-section">
1105 <h3 class="pim-section-title">{i18n::tr(k::PIM_USER)}</h3>
1106 {url_row(user, toast)}
1107 <p class="muted pim-hint">{i18n::tr(k::PIM_PASSWORD_HINT)}</p>
1108 <div class="pim-section-actions pim-left">
1109 <button type="button" class="btn btn-sm" on:click=app_passwords>
1110 {i18n::tr(k::PIM_APP_PASSWORDS)}
1111 </button>
1112 </div>
1113 </section>
1114 <section class="pim-section">
1115 <h3 class="pim-section-title">{i18n::tr(k::PIM_CLIENTS)}</h3>
1116 <ul class="pim-hints">
1117 <li>{i18n::tr(k::PIM_HINT_APPLE)}</li>
1118 <li>{i18n::tr(k::PIM_HINT_DAVX5)}</li>
1119 <li>{i18n::tr(k::PIM_HINT_THUNDERBIRD)}</li>
1120 <li>{i18n::tr(k::PIM_HINT_GNOME)}</li>
1121 </ul>
1122 </section>
1123 <section class="pim-section">
1124 <h3 class="pim-section-title">{i18n::tr(k::PIM_COLLECTION_URLS)}</h3>
1125 <div class="pim-items">
1126 {move || {
1127 collections
1128 .get()
1129 .unwrap_or_default()
1130 .into_iter()
1131 .map(|c| {
1132 let url = server_url(&c.url);
1133 let copy = url.clone();
1134 view! {
1135 <div class="pim-item">
1136 <span class="pim-swatch" style=format!("--swatch: {}", safe_color(c.color.as_deref()))></span>
1137 <span class="pim-item-name" title=url.clone()>{display_name(&c)}</span>
1138 <button
1139 type="button"
1140 class="btn btn-sm"
1141 on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
1142 >
1143 {icon_svg(IconName::Copy, "ic-btn")}
1144 {i18n::t(k::COPY)}
1145 </button>
1146 </div>
1147 }
1148 })
1149 .collect::<Vec<_>>()
1150 }}
1151 </div>
1152 </section>
1153 <div class="modal-actions">
1154 <button class="btn" on:click=move |_| on_close.run(())>{i18n::tr(k::CLOSE)}</button>
1155 </div>
1156 </Modal>
1157 }
1158}
1159