your personal website on atproto - mirror blento.app

Merge pull request #38 from jycouet/feat/vcard

`vCard` Card

authored by Florian and committed by GitHub 92a8d47f a895c753

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