
Benjamin Franklin
ben · Philadelphia Post Office
- cell+1 215 555 0176
- emailben@example.com
- e-IDe-id.to/franklin
- websiteen.wikipedia.org/wiki/Benjamin_Franklin
- homePhiladelphia, United States
Printer, writer, inventor.
a vCard drawn as a phone shows a contact: face, name, every field a row
npx shadcn add @ag/contactnpx shadcn add @ag/contact --dry-runYour installed source stays unchanged until you update it. How updates work
Contact, a vCard drawn as a phone shows a contact, from its text or its parsed fields (parseVcard, lib/vcard-core: 3.0 and 4.0, folded lines, escapes, Apple's labelled groups, an inline photo). It shows the face, the name and who they are, then each phone, email, page and address as a labelled row, a link where it opens something. It has no frame of its own and renders on the server.
ben · Philadelphia Post Office
Printer, writer, inventor.
in a box of tabs over a picture: the contact, and the .vcf it was drawn from
import { Sample } from "@/app/samples"
import { Code } from "@/registry/base-nova/ui/code"
import { Contact } from "@/registry/base-nova/ui/contact"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from "@/registry/base-nova/ui/tabs"
// #region franklin
// A contact as a phone writes it: a photo by address, Apple's labelled links, an
// escape in the note.
const FRANKLIN = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Benjamin Franklin",
"N:Franklin;Benjamin;;;",
"NICKNAME:ben",
"ORG:Philadelphia Post Office",
"PHOTO;VALUE=uri:/benjamin-franklin.png",
"item1.URL:https://e-id.to/franklin",
"item1.X-ABLabel:e-ID",
"item2.URL:https://en.wikipedia.org/wiki/Benjamin_Franklin",
"item2.X-ABLabel:website",
"EMAIL;TYPE=INTERNET:ben@example.com",
"TEL;TYPE=CELL:+1 215 555 0176",
"ADR;TYPE=HOME:;;;Philadelphia;;;United States",
"NOTE:Printer\\, writer\\, inventor.",
"END:VCARD",
].join("\r\n")
// #endregion
export default function ContactDemo() {
return (
<div className="space-y-8">
<Sample
name="contact"
glance="large"
title="a vCard, as a phone shows it"
with="franklin"
>
<div className="mx-auto w-full max-w-sm">
<Contact vcard={FRANKLIN} />
</div>
</Sample>
<Sample
name="file"
title="in glass, beside the file"
description="in a box of tabs over a picture: the contact, and the .vcf it was drawn from"
with="franklin"
>
<MaterialProvider material="glass">
<div className="grid place-items-center rounded-xl bg-[url(/glass-valley.jpg)] bg-cover bg-center p-8">
<Tabs defaultValue="contact" className="w-full max-w-md">
<Panel
maxHeight="36rem"
head={
<TabsList>
<TabsTab value="contact">contact</TabsTab>
<TabsTab value="vcf">.vcf</TabsTab>
</TabsList>
}
>
<TabsPanel value="contact" className="p-1">
<Contact vcard={FRANKLIN} />
</TabsPanel>
<TabsPanel value="vcf">
<Code bare lang="text">
{FRANKLIN}
</Code>
</TabsPanel>
</Panel>
</Tabs>
</div>
</MaterialProvider>
</Sample>
</div>
)
}import { cn } from "@/lib/utils"
import {
parseVcard,
type VCard,
type VCardField,
} from "@/registry/base-nova/lib/vcard-core"
import { Image } from "@/registry/base-nova/ui/image"
// A CONTACT AS A PHONE SHOWS ONE, drawn from a vCard (lib/vcard-core): the face
// round at the top, the name under it and who they are (nickname, organisation),
// then each field a row, what it is called small above what it holds, a link
// where it opens something (a page, mail, a call). Given the vCard's text or its
// parsed `VCard`. No frame of its own: it is content, set in whatever holds it (a
// box of tabs beside the file itself, a dialog, a page), and drawn in the current
// colour at alpha, so it reads in either material and scheme. No state: it renders
// on the server too.
//
// <Contact vcard={vcfText} />
/** A row's value as a link, where it opens something. */
function hrefOf(kind: "email" | "phone" | "url" | "address", value: string) {
if (kind === "email") return `mailto:${value}`
if (kind === "phone") return `tel:${value.replace(/[^\d+]/g, "")}`
if (kind === "url") return value
return null
}
/** A URL as people read it: no scheme, no `www.`, no trailing slash. */
const readable = (url: string) =>
url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")
/** What a row is called when its field has no label of its own. */
const KIND: Record<"email" | "phone" | "url" | "address", string> = {
email: "email",
phone: "phone",
url: "page",
address: "address",
}
function Row({
kind,
field,
}: {
kind: "email" | "phone" | "url" | "address"
field: VCardField
}) {
const href = hrefOf(kind, field.value)
const shown = kind === "url" ? readable(field.value) : field.value
return (
<li
data-slot="contact-row"
className="flex min-w-0 flex-col gap-0.5 py-2.5"
>
<span className="text-current/45 text-xs">
{field.label ?? KIND[kind]}
</span>
{href ? (
<a
href={href}
className="truncate text-[15px] text-current/90 underline-offset-4 hover:underline"
>
{shown}
</a>
) : (
<span className="truncate text-[15px] text-current/90">{shown}</span>
)}
</li>
)
}
/** The initials of a name, for a contact with no photo. */
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("")
export function Contact({
vcard,
className,
}: {
/** The vCard's text, or it parsed (lib/vcard-core, `parseVcard`). */
vcard: string | VCard
className?: string
}) {
const c = typeof vcard === "string" ? parseVcard(vcard) : vcard
const who = [c.nickname, c.title, c.org].filter(Boolean).join(" · ")
const rows = [
...c.phones.map((field) => ({ kind: "phone" as const, field })),
...c.emails.map((field) => ({ kind: "email" as const, field })),
...c.urls.map((field) => ({ kind: "url" as const, field })),
...c.addresses.map((field) => ({ kind: "address" as const, field })),
]
return (
<article
data-slot="contact"
className={cn("flex w-full flex-col items-center gap-5", className)}
>
<div
data-slot="contact-photo"
className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-full bg-current/8 font-medium text-2xl text-current/60"
>
{c.photo ? (
<Image src={c.photo} alt="" className="size-full object-cover" />
) : (
initials(c.name)
)}
</div>
<div className="flex flex-col items-center gap-1 text-center">
<h3 className="font-medium text-2xl tracking-[-0.02em]">{c.name}</h3>
{who && <p className="text-current/55 text-sm">{who}</p>}
</div>
{rows.length > 0 && (
<ul className="flex w-full flex-col divide-y divide-current/10 border-current/10 border-y">
{rows.map(({ kind, field }) => (
<Row key={`${kind}:${field.value}`} kind={kind} field={field} />
))}
</ul>
)}
{c.note && (
<p className="w-full whitespace-pre-line text-current/60 text-sm">
{c.note}
</p>
)}
</article>
)
}// A VCARD READ (RFC 6350, and the 3.0 every phone still writes): its text to the
// fields a person reads, for `contact` to draw. Lines unfolded (a line that goes on
// starts with a space), escapes undone, a property's group read (Apple's `item1.URL`
// labelled by `item1.X-ABLabel`), the structured name and address split, an inline
// photo made a data URL. No framework, no DOM. A text that is no vCard screams.
export interface VCardField {
value: string
/** What the field is called here: its group's label, else its TYPE (home,
* work), else none. */
label: string | null
}
export interface VCard {
/** The formatted name, FN, as it is shown. */
name: string
nickname: string | null
/** An image of them: a URL, or a data URL for an inline photo. */
photo: string | null
org: string | null
title: string | null
emails: VCardField[]
phones: VCardField[]
urls: VCardField[]
/** Each address as one line, its parts joined. */
addresses: VCardField[]
note: string | null
uid: string | null
}
interface Property {
group: string | null
name: string
params: Record<string, string[]>
value: string
}
/** The lines a vCard is folded into, put back together. */
function unfold(text: string): string[] {
return text
.replace(/\r\n|\r/g, "\n")
.replace(/\n[ \t]/g, "")
.split("\n")
.filter((line) => line.trim() !== "")
}
/** A value's escapes undone (`\n`, `\,`, `\;`, `\\`). */
function unescaped(value: string): string {
return value.replace(/\\([nN,;\\])/g, (_, c: string) =>
c === "n" || c === "N" ? "\n" : c,
)
}
/** A structured value's parts, split at the `;` no backslash escapes. */
function parts(value: string): string[] {
return value.split(/(?<!\\);/).map(unescaped)
}
function property(line: string): Property {
// The value starts after the first `:` outside a quoted parameter.
let quoted = false
let at = -1
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') quoted = !quoted
else if (line[i] === ":" && !quoted) {
at = i
break
}
}
if (at < 0)
throw new Error(`vcard: a line with no value: ${line.slice(0, 60)}`)
const [head = "", ...rawParams] = line.slice(0, at).split(";")
const dot = head.indexOf(".")
const params: Record<string, string[]> = {}
for (const raw of rawParams) {
// A bare parameter (3.0's `;HOME`) is a TYPE.
const eq = raw.indexOf("=")
const [key, val] =
eq < 0 ? ["TYPE", raw] : [raw.slice(0, eq), raw.slice(eq + 1)]
const name = (key ?? "").toUpperCase()
params[name] = [
...(params[name] ?? []),
...(val ?? "").split(",").map((v) => v.replace(/^"|"$/g, "")),
]
}
return {
group: dot < 0 ? null : head.slice(0, dot),
name: (dot < 0 ? head : head.slice(dot + 1)).toUpperCase(),
params,
value: line.slice(at + 1),
}
}
/** The types a field is not called by: how it travels, never what it is. */
const NOT_A_LABEL = new Set(["internet", "pref", "x400", "voice"])
export function parseVcard(text: string): VCard {
const lines = unfold(text)
if (lines[0]?.toUpperCase() !== "BEGIN:VCARD")
throw new Error("vcard: it does not start with BEGIN:VCARD")
const props = lines.slice(1).map(property)
const labels = new Map(
props
.filter((p) => p.name === "X-ABLABEL" && p.group)
.map((p) => [p.group as string, unescaped(p.value)]),
)
const labelOf = (p: Property) => {
if (p.group && labels.has(p.group)) return labels.get(p.group) ?? null
const type = (p.params.TYPE ?? [])
.map((t) => t.toLowerCase())
.find((t) => !NOT_A_LABEL.has(t))
return type ?? null
}
const all = (name: string) => props.filter((p) => p.name === name)
const one = (name: string) => {
const p = all(name)[0]
return p ? unescaped(p.value) : null
}
const field = (p: Property): VCardField => ({
value: unescaped(p.value),
label: labelOf(p),
})
const photoProp = all("PHOTO")[0]
const photo = photoProp
? (photoProp.params.ENCODING ?? []).some((e) => /^b(ase64)?$/i.test(e))
? `data:image/${(photoProp.params.TYPE?.[0] ?? "jpeg").toLowerCase()};base64,${photoProp.value}`
: photoProp.value
: null
const n = one("N")
const fn =
one("FN") ?? (n ? parts(n).filter(Boolean).reverse().join(" ") : null)
if (!fn) throw new Error("vcard: it names nobody (no FN, no N)")
return {
name: fn,
nickname: one("NICKNAME"),
photo,
org: (() => {
const o = one("ORG")
return o ? parts(o).filter(Boolean).join(", ") : null
})(),
title: one("TITLE"),
emails: all("EMAIL").map(field),
phones: all("TEL").map(field),
urls: all("URL").map(field),
addresses: all("ADR").map((p) => ({
value: parts(p.value).filter(Boolean).join(", "),
label: labelOf(p),
})),
note: one("NOTE"),
uid: one("UID"),
}
}