tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
vCard is here
jycouet
2 weeks ago
09e26cfc
1fd59d31
+327
-1
4 changed files
expand all
collapse all
unified
split
src
lib
cards
VCardCard
VCardCard.svelte
VCardCardSettings.svelte
index.ts
index.ts
+70
src/lib/cards/VCardCard/VCardCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Modal } from '@foxui/core';
3
3
+
import QRCodeDisplay from '$lib/components/qr/QRCodeDisplay.svelte';
4
4
+
import type { ContentComponentProps } from '../types';
5
5
+
import { parseVCardName, parseVCardOrg } from '.';
6
6
+
7
7
+
let { item }: ContentComponentProps = $props();
8
8
+
9
9
+
let showQR = $state(false);
10
10
+
11
11
+
let displayName = $derived(
12
12
+
item.cardData.displayName || parseVCardName(item.cardData.vcard || '') || 'Contact'
13
13
+
);
14
14
+
let org = $derived(parseVCardOrg(item.cardData.vcard || ''));
15
15
+
</script>
16
16
+
17
17
+
<button
18
18
+
class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-3 p-3"
19
19
+
onclick={() => (showQR = true)}
20
20
+
><div
21
21
+
class="text-base-500 dark:text-base-400 accent:text-base-700 text-[12px] font-medium tracking-wide uppercase"
22
22
+
>
23
23
+
vCard
24
24
+
</div>
25
25
+
<!-- Identification Card icon (Heroicons) -->
26
26
+
<svg
27
27
+
xmlns="http://www.w3.org/2000/svg"
28
28
+
fill="none"
29
29
+
viewBox="0 0 24 24"
30
30
+
stroke-width="1.5"
31
31
+
stroke="currentColor"
32
32
+
class="text-base-700 dark:text-base-300 accent:text-base-900 size-10"
33
33
+
>
34
34
+
<path
35
35
+
stroke-linecap="round"
36
36
+
stroke-linejoin="round"
37
37
+
d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z"
38
38
+
/>
39
39
+
</svg>
40
40
+
41
41
+
<div class="text-center">
42
42
+
<div class="text-base-900 dark:text-base-100 accent:text-base-900 text-sm font-semibold">
43
43
+
{displayName}
44
44
+
</div>
45
45
+
{#if org}
46
46
+
<div class="text-base-600 dark:text-base-400 accent:text-base-800 text-xs">
47
47
+
{org}
48
48
+
</div>
49
49
+
{/if}
50
50
+
</div>
51
51
+
</button>
52
52
+
53
53
+
<Modal bind:open={showQR} closeButton={true} class="max-w-[90vw]! sm:max-w-sm! md:max-w-md!">
54
54
+
<div class="flex flex-col items-center justify-center gap-4 p-4">
55
55
+
<div class="text-base-900 dark:text-base-100 text-center text-2xl font-semibold">
56
56
+
{displayName}
57
57
+
</div>
58
58
+
59
59
+
<div class="flex items-center justify-center overflow-hidden rounded-2xl">
60
60
+
<QRCodeDisplay
61
61
+
url={item.cardData.vcard || ''}
62
62
+
class="size-[min(70vw,320px)] sm:size-72 md:size-80"
63
63
+
/>
64
64
+
</div>
65
65
+
66
66
+
<p class="text-base-600 dark:text-base-400 text-center text-sm">
67
67
+
Scan to add contact to your phone
68
68
+
</p>
69
69
+
</div>
70
70
+
</Modal>
+128
src/lib/cards/VCardCard/VCardCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Alert, Button, Subheading } from '@foxui/core';
3
3
+
import type { SettingsComponentProps } from '../types';
4
4
+
import { parseVCard, generateVCard, parseVCardName, emptyVCardFields, type VCardFields } from '.';
5
5
+
6
6
+
let { item = $bindable(), onclose }: SettingsComponentProps = $props();
7
7
+
8
8
+
let mode: 'easy' | 'expert' = $state('easy');
9
9
+
let fields: VCardFields = $state(
10
10
+
parseVCard(item.cardData.vcard || '') || { ...emptyVCardFields }
11
11
+
);
12
12
+
13
13
+
function syncFromFields() {
14
14
+
item.cardData.vcard = generateVCard(fields);
15
15
+
item.cardData.displayName = parseVCardName(item.cardData.vcard);
16
16
+
}
17
17
+
18
18
+
function handleTextarea(e: Event) {
19
19
+
const text = (e.target as HTMLTextAreaElement).value;
20
20
+
item.cardData.vcard = text;
21
21
+
item.cardData.displayName = parseVCardName(text);
22
22
+
fields = parseVCard(text);
23
23
+
}
24
24
+
</script>
25
25
+
26
26
+
<div class="flex w-72 flex-col gap-3 p-2">
27
27
+
<Subheading>Edit vCard</Subheading>
28
28
+
29
29
+
<Alert type="info" title="Privacy">
30
30
+
<p class="text-xs">All data is public, be aware.</p>
31
31
+
</Alert>
32
32
+
33
33
+
<div class="flex items-center gap-2 text-xs">
34
34
+
<button
35
35
+
class={[
36
36
+
'rounded px-2 py-1',
37
37
+
mode === 'easy' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700'
38
38
+
]}
39
39
+
onclick={() => (mode = 'easy')}
40
40
+
>
41
41
+
Easy
42
42
+
</button>
43
43
+
<button
44
44
+
class={[
45
45
+
'rounded px-2 py-1',
46
46
+
mode === 'expert' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700'
47
47
+
]}
48
48
+
onclick={() => (mode = 'expert')}
49
49
+
>
50
50
+
Expert
51
51
+
</button>
52
52
+
<a
53
53
+
href="https://wikipedia.org/wiki/VCard"
54
54
+
target="_blank"
55
55
+
class="text-accent-600 dark:text-accent-400 underline">Learn about the vCard format</a
56
56
+
>
57
57
+
</div>
58
58
+
59
59
+
{#if mode === 'easy'}
60
60
+
<div class="flex flex-col gap-1 text-xs">
61
61
+
<div class="grid grid-cols-2 gap-1">
62
62
+
<input
63
63
+
bind:value={fields.firstName}
64
64
+
oninput={syncFromFields}
65
65
+
placeholder="First name"
66
66
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
67
67
+
/>
68
68
+
<input
69
69
+
bind:value={fields.lastName}
70
70
+
oninput={syncFromFields}
71
71
+
placeholder="Last name"
72
72
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
73
73
+
/>
74
74
+
</div>
75
75
+
<input
76
76
+
bind:value={fields.org}
77
77
+
oninput={syncFromFields}
78
78
+
placeholder="Organization"
79
79
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
80
80
+
/>
81
81
+
<input
82
82
+
bind:value={fields.title}
83
83
+
oninput={syncFromFields}
84
84
+
placeholder="Job title"
85
85
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
86
86
+
/>
87
87
+
<input
88
88
+
bind:value={fields.email}
89
89
+
oninput={syncFromFields}
90
90
+
placeholder="Email"
91
91
+
type="email"
92
92
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
93
93
+
/>
94
94
+
<input
95
95
+
bind:value={fields.bday}
96
96
+
oninput={syncFromFields}
97
97
+
placeholder="Birthday"
98
98
+
type="date"
99
99
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
100
100
+
/>
101
101
+
<input
102
102
+
bind:value={fields.website}
103
103
+
oninput={syncFromFields}
104
104
+
placeholder="Website"
105
105
+
type="url"
106
106
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
107
107
+
/>
108
108
+
<input
109
109
+
bind:value={fields.address}
110
110
+
oninput={syncFromFields}
111
111
+
placeholder="Address"
112
112
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1"
113
113
+
/>
114
114
+
</div>
115
115
+
{:else}
116
116
+
<textarea
117
117
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 h-40 w-full resize-none rounded border p-2 font-mono text-xs focus:outline-none"
118
118
+
value={item.cardData.vcard || ''}
119
119
+
oninput={handleTextarea}
120
120
+
placeholder="BEGIN:VCARD
121
121
+
VERSION:4.0
122
122
+
FN:John Doe
123
123
+
END:VCARD"
124
124
+
></textarea>
125
125
+
{/if}
126
126
+
127
127
+
<Button onclick={onclose} size="sm">Done</Button>
128
128
+
</div>
+126
src/lib/cards/VCardCard/index.ts
···
1
1
+
import { user } from '$lib/atproto/auth.svelte';
2
2
+
import type { CardDefinition } from '../types';
3
3
+
import VCardCard from './VCardCard.svelte';
4
4
+
import VCardCardSettings from './VCardCardSettings.svelte';
5
5
+
6
6
+
// vCard spec: https://wikipedia.org/wiki/VCard
7
7
+
8
8
+
export type VCardFields = {
9
9
+
firstName: string;
10
10
+
lastName: string;
11
11
+
org: string;
12
12
+
title: string;
13
13
+
email: string;
14
14
+
bday: string; // YYYY-MM-DD for input, stored as YYYYMMDD
15
15
+
website: string;
16
16
+
address: string;
17
17
+
note: string;
18
18
+
};
19
19
+
20
20
+
export const emptyVCardFields: VCardFields = {
21
21
+
firstName: '',
22
22
+
lastName: '',
23
23
+
org: '',
24
24
+
title: '',
25
25
+
email: '',
26
26
+
bday: '',
27
27
+
website: '',
28
28
+
address: '',
29
29
+
note: ''
30
30
+
};
31
31
+
32
32
+
// Convert YYYY-MM-DD to YYYYMMDD for vCard
33
33
+
export function formatBdayToVCard(date: string): string {
34
34
+
return date.replace(/-/g, '');
35
35
+
}
36
36
+
37
37
+
// Convert YYYYMMDD to YYYY-MM-DD for input
38
38
+
export function formatBdayFromVCard(bday: string): string {
39
39
+
if (bday.length === 8) {
40
40
+
return `${bday.slice(0, 4)}-${bday.slice(4, 6)}-${bday.slice(6, 8)}`;
41
41
+
}
42
42
+
return bday;
43
43
+
}
44
44
+
45
45
+
// Generate vCard v4 string from fields
46
46
+
export function generateVCard(f: VCardFields): string {
47
47
+
const lines = ['BEGIN:VCARD', 'VERSION:4.0'];
48
48
+
const fn = `${f.firstName} ${f.lastName}`.trim();
49
49
+
if (fn) lines.push(`FN:${fn}`);
50
50
+
if (f.lastName || f.firstName) lines.push(`N:${f.lastName};${f.firstName};;;`);
51
51
+
if (f.org) lines.push(`ORG:${f.org}`);
52
52
+
if (f.title) lines.push(`TITLE:${f.title}`);
53
53
+
if (f.email) lines.push(`EMAIL:${f.email}`);
54
54
+
if (f.bday) lines.push(`BDAY:${formatBdayToVCard(f.bday)}`);
55
55
+
if (f.website) lines.push(`URL:${f.website}`);
56
56
+
if (f.address) lines.push(`ADR:;;${f.address};;;;`);
57
57
+
if (f.note) lines.push(`NOTE:${f.note}`);
58
58
+
lines.push('END:VCARD');
59
59
+
return lines.join('\n');
60
60
+
}
61
61
+
62
62
+
// Parse vCard string to fields (supports v3 & v4)
63
63
+
export function parseVCard(vcard: string): VCardFields {
64
64
+
const get = (key: string) => {
65
65
+
const m = vcard.match(new RegExp(`^${key}[;:](.*)$`, 'im'));
66
66
+
return m?.[1]?.trim() || '';
67
67
+
};
68
68
+
69
69
+
const n = get('N').split(';');
70
70
+
let lastName = n[0] || '';
71
71
+
let firstName = n[1] || '';
72
72
+
73
73
+
if (!lastName && !firstName) {
74
74
+
const fn = get('FN').split(' ');
75
75
+
firstName = fn[0] || '';
76
76
+
lastName = fn.slice(1).join(' ') || '';
77
77
+
}
78
78
+
79
79
+
const adr = get('ADR').split(';');
80
80
+
81
81
+
return {
82
82
+
firstName,
83
83
+
lastName,
84
84
+
org: get('ORG').split(';')[0],
85
85
+
title: get('TITLE'),
86
86
+
email: get('EMAIL'),
87
87
+
bday: formatBdayFromVCard(get('BDAY')),
88
88
+
website: get('URL'),
89
89
+
address: adr[2] || '',
90
90
+
note: get('NOTE')
91
91
+
};
92
92
+
}
93
93
+
94
94
+
// Parse FN (formatted name) or N from vCard
95
95
+
export function parseVCardName(vcard: string): string {
96
96
+
const f = parseVCard(vcard);
97
97
+
return `${f.firstName} ${f.lastName}`.trim();
98
98
+
}
99
99
+
100
100
+
// Parse ORG from vCard
101
101
+
export function parseVCardOrg(vcard: string): string {
102
102
+
return parseVCard(vcard).org;
103
103
+
}
104
104
+
105
105
+
export const VCardCardDefinition = {
106
106
+
type: 'vcard',
107
107
+
contentComponent: VCardCard,
108
108
+
settingsComponent: VCardCardSettings,
109
109
+
110
110
+
createNew: (card) => {
111
111
+
card.w = 2;
112
112
+
card.h = 2;
113
113
+
card.mobileW = 4;
114
114
+
card.mobileH = 4;
115
115
+
const displayName = user.profile?.displayName || user.profile?.handle || '';
116
116
+
card.cardData.vcard = generateVCard({
117
117
+
...emptyVCardFields,
118
118
+
lastName: displayName
119
119
+
});
120
120
+
card.cardData.displayName = displayName;
121
121
+
},
122
122
+
123
123
+
sidebarButtonText: 'vCard',
124
124
+
allowSetColor: true,
125
125
+
name: 'vCard Card'
126
126
+
} as CardDefinition & { type: 'vcard' };
+3
-1
src/lib/cards/index.ts
···
26
26
import { StandardSiteDocumentListCardDefinition } from './StandardSiteDocumentListCard';
27
27
import { StatusphereCardDefinition } from './StatusphereCard';
28
28
import { EventCardDefinition } from './EventCard';
29
29
+
import { VCardCardDefinition } from './VCardCard';
29
30
30
31
export const AllCardDefinitions = [
31
32
ImageCardDefinition,
···
54
55
PhotoGalleryCardDefinition,
55
56
StandardSiteDocumentListCardDefinition,
56
57
StatusphereCardDefinition,
57
57
-
EventCardDefinition
58
58
+
EventCardDefinition,
59
59
+
VCardCardDefinition
58
60
] as const;
59
61
60
62
export const CardDefinitionsByType = AllCardDefinitions.reduce(