@axium/contacts 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { Address, Contact, InitNoExternal, Phone } from '../common.js';
1
+ import type { Address, Contact, InitNoExternal, Phone, SigDateLike } from '../common.js';
2
2
  /**
3
3
  * Display name for contact
4
4
  * @todo localize
@@ -17,12 +17,5 @@ export declare function phoneDefault(contact: Contact): string;
17
17
  */
18
18
  export declare function address(addr: Address): string;
19
19
  export declare function birthDate(contact: Contact): string;
20
- interface SigDateLike {
21
- year?: number | null;
22
- month?: number | null;
23
- day?: number | null;
24
- label?: string | null;
25
- }
26
20
  export declare function date(sig: SigDateLike): string;
27
21
  export declare function job(contact: InitNoExternal): string;
28
- export {};
package/dist/common.d.ts CHANGED
@@ -35,6 +35,12 @@ export declare const SigDate: z.ZodObject<{
35
35
  }, z.core.$strip>;
36
36
  export interface SigDate extends z.infer<typeof SigDate> {
37
37
  }
38
+ export interface SigDateLike {
39
+ year?: number | null;
40
+ month?: number | null;
41
+ day?: number | null;
42
+ label?: string | null;
43
+ }
38
44
  export declare const Relationship: z.ZodObject<{
39
45
  to: z.ZodUUID;
40
46
  label: z.ZodString;
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './common.js';
2
+ export * from './vcard.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from './common.js';
2
+ export * from './vcard.js';
@@ -0,0 +1,2 @@
1
+ import type { Contact } from '@axium/contacts';
2
+ export declare function toVCard(contact: Contact): string;
package/dist/vcard.js ADDED
@@ -0,0 +1,82 @@
1
+ function encodeDate(date) {
2
+ const y = date.year ? String(date.year).padStart(4, '0') : '--';
3
+ const m = date.month ? String(date.month).padStart(2, '0') : '-';
4
+ const d = date.day ? String(date.day).padStart(2, '0') : '';
5
+ return `${y}-${m}-${d}`;
6
+ }
7
+ export function toVCard(contact) {
8
+ const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
9
+ function escape(str) {
10
+ if (!str)
11
+ return '';
12
+ return str.replace(/([,;\\])/g, '\\$1').replace(/\n/g, '\\n');
13
+ }
14
+ const n = [contact.surname, contact.givenName, contact.givenName2, contact.prefix, contact.suffix];
15
+ if (n.some(Boolean)) {
16
+ lines.push(`N:${n.map(escape).join(';')}`);
17
+ }
18
+ if (contact.display) {
19
+ lines.push(`FN:${escape(contact.display)}`);
20
+ }
21
+ else {
22
+ const full = [contact.prefix, contact.givenName, contact.givenName2, contact.surname, contact.suffix].filter(Boolean).join(' ');
23
+ lines.push(`FN:${escape(full) || 'Unknown'}`);
24
+ }
25
+ if (contact.nickname)
26
+ lines.push(`NICKNAME:${escape(contact.nickname)}`);
27
+ if (contact.company || contact.department) {
28
+ lines.push(`ORG:${escape(contact.company)};${escape(contact.department)}`);
29
+ }
30
+ if (contact.jobTitle)
31
+ lines.push(`TITLE:${escape(contact.jobTitle)}`);
32
+ if (contact.notes)
33
+ lines.push(`NOTE:${escape(contact.notes)}`);
34
+ if (contact.birthYear || contact.birthMonth || contact.birthDay) {
35
+ lines.push(`BDAY:${encodeDate({ year: contact.birthYear, month: contact.birthMonth, day: contact.birthDay })}`);
36
+ }
37
+ for (const url of contact.urls) {
38
+ lines.push(`URL:${escape(url)}`);
39
+ }
40
+ for (const email of contact.emails) {
41
+ const type = email.label ? `;TYPE=${escape(email.label)}` : '';
42
+ const pref = email.isDefault ? ';TYPE=PREF' : '';
43
+ lines.push(`EMAIL${type}${pref}:${escape(email.email)}`);
44
+ }
45
+ for (const phone of contact.phones) {
46
+ const type = phone.label ? `;TYPE=${escape(phone.label)}` : '';
47
+ const pref = phone.isDefault ? ';TYPE=PREF' : '';
48
+ const country = phone.country ? `+${phone.country}` : '';
49
+ lines.push(`TEL${type}${pref}:${country}${phone.number}`);
50
+ }
51
+ for (const addr of contact.addresses) {
52
+ const type = addr.label ? `;TYPE=${escape(addr.label)}` : '';
53
+ const pref = addr.isDefault ? ';TYPE=PREF' : '';
54
+ const components = [
55
+ '', // PO Box
56
+ addr.street2,
57
+ addr.street1,
58
+ addr.locality,
59
+ addr.subdivision,
60
+ addr.postalCode,
61
+ addr.country,
62
+ ];
63
+ lines.push(`ADR${type}${pref}:${components.map(escape).join(';')}`);
64
+ }
65
+ for (const date of contact.dates) {
66
+ const type = date.label ? `;TYPE=${escape(date.label)}` : '';
67
+ lines.push(`ANNIVERSARY${type}:${encodeDate(date)}`);
68
+ }
69
+ for (const rel of contact.relationships) {
70
+ // @todo get name of `to` since other software would just see the UUID
71
+ const type = rel.label ? `;TYPE=${escape(rel.label)}` : '';
72
+ lines.push(`RELATED${type}:${escape(rel.to)}`);
73
+ }
74
+ for (const custom of contact.custom) {
75
+ const safeLabel = custom.label?.replace(/[^a-zA-Z0-9-]/g, '');
76
+ if (!safeLabel)
77
+ continue;
78
+ lines.push(`X-${safeLabel}:${escape(custom.value)}`);
79
+ }
80
+ lines.push('END:VCARD');
81
+ return lines.join('\r\n');
82
+ }
@@ -6,6 +6,7 @@
6
6
  import ContactPicture from './ContactPicture.svelte';
7
7
  import DateSelect from './DateSelect.svelte';
8
8
  import Discovery from './Discovery.svelte';
9
+ import { name as formatName } from '@axium/contacts/client/format';
9
10
 
10
11
  let showDetailed = $state(false);
11
12
 
@@ -54,6 +55,8 @@
54
55
  </button>
55
56
  </a>
56
57
 
58
+ <span></span>
59
+
57
60
  <button class="icon-text save" onclick={() => save(init)}>
58
61
  <span>{text('contacts.init.save')}</span>
59
62
  </button>
@@ -61,7 +64,8 @@
61
64
 
62
65
  {#if init.id}
63
66
  <div class="contact-init-header">
64
- <ContactPicture contact={init as typeof init & { id: string }} --size="100px" />
67
+ <ContactPicture contact={init as typeof init & { id: string }} --size="150px" />
68
+ <span class="contact-name-title">{formatName(init)}</span>
65
69
  </div>
66
70
  {/if}
67
71
 
@@ -196,34 +200,44 @@
196
200
  </div>
197
201
 
198
202
  <style>
203
+ .contact-name-title {
204
+ margin-left: 2em;
205
+ font-size: 2em;
206
+ }
207
+
199
208
  .contact-init-header,
200
209
  .contact-init,
201
210
  .contact-init-actions {
202
211
  padding: 2em;
203
- width: 700px;
212
+ margin: 1em;
213
+ width: calc(700px - 2em);
204
214
 
205
215
  @media (width < 700px) {
206
- width: 100%;
216
+ width: calc(100% - 2em);
207
217
  }
208
218
  }
209
219
 
210
- .contact-init-header {
211
- display: flex;
212
- align-items: center;
213
- justify-content: center;
214
- }
215
-
220
+ .contact-init-header,
216
221
  .contact-init-actions {
217
222
  display: flex;
218
223
  gap: 1em;
219
224
  align-items: center;
220
- justify-content: space-between;
225
+ }
226
+
227
+ .contact-init-actions > :not(span) {
228
+ flex: 0 0 auto;
229
+ }
230
+
231
+ .contact-init-actions > span {
232
+ flex: 1 1 auto;
221
233
  }
222
234
 
223
235
  .contact-init {
224
236
  display: grid;
225
237
  grid-template-columns: 1em 1fr 1em;
226
238
  gap: 1em;
239
+ border-radius: 1em;
240
+ background-color: var(--bg-menu);
227
241
 
228
242
  button.toggle {
229
243
  height: 1em;
package/locales/en.json CHANGED
@@ -42,7 +42,8 @@
42
42
  "Discovery": {
43
43
  "placeholder": "Search for a contact",
44
44
  "no_results": "No contacts found"
45
- }
45
+ },
46
+ "delete_confirm": "Are you sure you want to delete this contact?"
46
47
  },
47
48
  "contact": {
48
49
  "label": "Label",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/contacts",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "Contacts for Axium",
6
6
  "funding": {
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
- import { text } from '@axium/client';
3
- import { Icon, Popover } from '@axium/client/components';
2
+ import { fetchAPI, text } from '@axium/client';
3
+ import { FormDialog, Icon, Popover } from '@axium/client/components';
4
4
  import { toast, toastStatus } from '@axium/client/toast';
5
+ import { toVCard } from '@axium/contacts';
5
6
  import { format, getContact } from '@axium/contacts/client';
6
7
  import { ContactPicture, Field } from '@axium/contacts/components';
7
- import { upload } from 'utilium/dom.js';
8
+ import { download, upload } from 'utilium/dom.js';
8
9
 
9
10
  const { data } = $props();
10
11
  const { contact } = data;
@@ -32,6 +33,8 @@
32
33
  await toast('error', e);
33
34
  }
34
35
  }
36
+
37
+ const contactFileName = contact.emails.find(e => e.isDefault)?.email || format.name(contact).replaceAll(' ', '_');
35
38
  </script>
36
39
 
37
40
  <svelte:head>
@@ -55,12 +58,12 @@
55
58
  </button>
56
59
  </a>
57
60
 
58
- <button class="icon-text">
61
+ <button class="icon-text" command="show-modal" commandfor="delete-contact">
59
62
  <Icon i="trash" />
60
63
  <span>{text('contacts.delete')}</span>
61
64
  </button>
62
65
 
63
- <button class="icon-text">
66
+ <button class="icon-text" onclick={() => download(contactFileName + '.vcard', toVCard(contact))}>
64
67
  <Icon i="file-export" />
65
68
  <span>{text('contacts.export')}</span>
66
69
  </button>
@@ -176,6 +179,19 @@
176
179
  {@render part('regular/note', contact.notes)}
177
180
  </div>
178
181
 
182
+ <FormDialog
183
+ id="delete-contact"
184
+ submitDanger
185
+ submitText={text('generic.delete')}
186
+ submit={() => fetchAPI('DELETE', 'contacts/:id', {}, contact.id).then(() => (window.location.href = '/contacts'))}
187
+ >
188
+ <p>
189
+ <span>{text('contacts.delete_confirm')}</span>
190
+ <br />
191
+ <strong>{text('generic.action_irreversible')}</strong>
192
+ </p>
193
+ </FormDialog>
194
+
179
195
  <style>
180
196
  .contact-image-container {
181
197
  width: 150px;
@@ -201,10 +217,11 @@
201
217
  .contact,
202
218
  .contact-actions {
203
219
  padding: 2em;
204
- width: 700px;
220
+ margin: 1em;
221
+ width: calc(700px - 2em);
205
222
 
206
223
  @media (width < 700px) {
207
- width: 100%;
224
+ width: calc(100% - 2em);
208
225
  }
209
226
  }
210
227
 
@@ -227,6 +244,8 @@
227
244
  display: grid;
228
245
  grid-template-columns: 1em 1fr;
229
246
  gap: 1em;
247
+ border-radius: 1em;
248
+ background-color: var(--bg-menu);
230
249
 
231
250
  button.toggle {
232
251
  height: 1em;