Skip to content

Commit 55df191

Browse files
committed
feat: add UserAvatar with three-tier fallback (photo → Gravatar → generative SVG)
BeamAvatar: deterministic face-like SVG from any string, zero deps, boring-avatars beam algorithm UserAvatar: three-tier fallback — uploaded photo, Gravatar SHA-256, generative beam SVG UserOrgDropdown now uses UserAvatar instead of simple letter initials
1 parent 18732f8 commit 55df191

File tree

5 files changed

+231
-26
lines changed

5 files changed

+231
-26
lines changed

pkg/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hanzo/ui",
3-
"version": "5.3.38",
3+
"version": "5.3.39",
44
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage.",
55
"publishConfig": {
66
"registry": "https://registry.npmjs.org/",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
const COLORS = ['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']
6+
7+
function hashStr(str: string): number {
8+
let hash = 0
9+
for (let i = 0; i < str.length; i++) {
10+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
11+
}
12+
return Math.abs(hash)
13+
}
14+
15+
function getUnit(hash: number, range: number, index: number): number {
16+
const val = hash % (range * (index + 1))
17+
return val % range
18+
}
19+
20+
function getBoolean(hash: number, index: number): boolean {
21+
return getUnit(hash, 2, index) === 0
22+
}
23+
24+
function getRandomColor(hash: number, index: number, colors: string[]): string {
25+
return colors[getUnit(hash, colors.length, index)]
26+
}
27+
28+
function getContrast(hex: string): string {
29+
const r = parseInt(hex.slice(1, 3), 16)
30+
const g = parseInt(hex.slice(3, 5), 16)
31+
const b = parseInt(hex.slice(5, 7), 16)
32+
return r * 0.299 + g * 0.587 + b * 0.114 > 128 ? '#000' : '#fff'
33+
}
34+
35+
export interface BeamAvatarProps {
36+
name: string
37+
size?: number
38+
colors?: string[]
39+
className?: string
40+
square?: boolean
41+
}
42+
43+
export function BeamAvatar({
44+
name,
45+
size = 40,
46+
colors = COLORS,
47+
className,
48+
square = false,
49+
}: BeamAvatarProps) {
50+
const hash = hashStr(name)
51+
const wrapperColor = getRandomColor(hash, 0, colors)
52+
const faceColor = getContrast(wrapperColor)
53+
const isOpen = getBoolean(hash, 2)
54+
const mouthSpread = getUnit(hash, 3, 7)
55+
const eyeSpread = getUnit(hash, 5, 8)
56+
const faceRotate = getUnit(hash, 10, 9)
57+
const faceTranslateX = faceRotate > 6 ? faceRotate - 10 : faceRotate
58+
const faceTranslateY = getUnit(hash, 5, 10) > 3 ? getUnit(hash, 5, 10) - 5 : getUnit(hash, 5, 10)
59+
60+
return (
61+
<svg
62+
viewBox="0 0 36 36"
63+
fill="none"
64+
xmlns="http://www.w3.org/2000/svg"
65+
width={size}
66+
height={size}
67+
className={className}
68+
style={square ? undefined : { borderRadius: '50%' }}
69+
>
70+
<mask id={`beam-${hash}`} maskUnits="userSpaceOnUse" x={0} y={0} width={36} height={36}>
71+
<rect width={36} height={36} rx={square ? undefined : 72} fill="#fff" />
72+
</mask>
73+
<g mask={`url(#beam-${hash})`}>
74+
<rect width={36} height={36} fill={wrapperColor} />
75+
<rect
76+
x={0}
77+
y={0}
78+
width={36}
79+
height={36}
80+
transform={`translate(${faceTranslateX} ${faceTranslateY}) rotate(${faceRotate} 18 18)`}
81+
fill={getRandomColor(hash, 1, colors)}
82+
rx={6}
83+
/>
84+
<g
85+
transform={`translate(${faceTranslateX} ${faceTranslateY}) rotate(${faceRotate} 18 18)`}
86+
>
87+
{/* Mouth */}
88+
{isOpen ? (
89+
<path
90+
d={`M15 ${19 + mouthSpread}c2 1 4 1 6 0`}
91+
stroke={faceColor}
92+
fill="none"
93+
strokeLinecap="round"
94+
/>
95+
) : (
96+
<path
97+
d={`M13 ${19 + mouthSpread}a1 .75 0 0 0 10 0`}
98+
fill={faceColor}
99+
/>
100+
)}
101+
{/* Eyes */}
102+
<rect
103+
x={14 - eyeSpread}
104+
y={14}
105+
width={1.5}
106+
height={2}
107+
rx={1}
108+
fill={faceColor}
109+
/>
110+
<rect
111+
x={20 + eyeSpread}
112+
y={14}
113+
width={1.5}
114+
height={2}
115+
rx={1}
116+
fill={faceColor}
117+
/>
118+
</g>
119+
</g>
120+
</svg>
121+
)
122+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client'
2+
3+
import React, { useState, useMemo } from 'react'
4+
import { BeamAvatar } from './BeamAvatar'
5+
6+
export interface UserAvatarProps {
7+
/** Direct image URL (uploaded photo) */
8+
src?: string
9+
/** Email for Gravatar lookup */
10+
email?: string
11+
/** Name for generative fallback seed */
12+
name?: string
13+
/** Size in px */
14+
size?: number
15+
/** Custom colors for generative avatar */
16+
colors?: string[]
17+
className?: string
18+
}
19+
20+
async function sha256(message: string): Promise<string> {
21+
const encoder = new TextEncoder()
22+
const data = encoder.encode(message)
23+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
24+
return Array.from(new Uint8Array(hashBuffer))
25+
.map((b) => b.toString(16).padStart(2, '0'))
26+
.join('')
27+
}
28+
29+
function useGravatarUrl(email?: string, size?: number): string | null {
30+
const [url, setUrl] = useState<string | null>(null)
31+
32+
useMemo(() => {
33+
if (!email) {
34+
setUrl(null)
35+
return
36+
}
37+
const trimmed = email.trim().toLowerCase()
38+
sha256(trimmed).then((hash) => {
39+
setUrl(`https://www.gravatar.com/avatar/${hash}?s=${size || 80}&d=404`)
40+
})
41+
}, [email, size])
42+
43+
return url
44+
}
45+
46+
export function UserAvatar({
47+
src,
48+
email,
49+
name,
50+
size = 40,
51+
colors,
52+
className,
53+
}: UserAvatarProps) {
54+
const [photoFailed, setPhotoFailed] = useState(false)
55+
const [gravatarFailed, setGravatarFailed] = useState(false)
56+
const gravatarUrl = useGravatarUrl(email, size * 2)
57+
58+
const imgClass = `rounded-full object-cover ${className || ''}`
59+
const style = { width: size, height: size }
60+
61+
// Tier 1: uploaded/provided photo
62+
if (src && !photoFailed) {
63+
return (
64+
<img
65+
src={src}
66+
alt={name || email || 'avatar'}
67+
className={imgClass}
68+
style={style}
69+
onError={() => setPhotoFailed(true)}
70+
/>
71+
)
72+
}
73+
74+
// Tier 2: Gravatar
75+
if (gravatarUrl && !gravatarFailed) {
76+
return (
77+
<img
78+
src={gravatarUrl}
79+
alt={name || email || 'avatar'}
80+
className={imgClass}
81+
style={style}
82+
onError={() => setGravatarFailed(true)}
83+
/>
84+
)
85+
}
86+
87+
// Tier 3: generative beam avatar
88+
const seed = name || email || 'user'
89+
return (
90+
<BeamAvatar
91+
name={seed}
92+
size={size}
93+
colors={colors}
94+
className={className}
95+
/>
96+
)
97+
}

pkg/ui/src/navigation/hanzo-shell/UserOrgDropdown.tsx

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React, { useState, useRef, useEffect } from 'react'
44
import type { HanzoUser, HanzoOrg } from './types'
5+
import { UserAvatar } from './UserAvatar'
56

67
interface UserOrgDropdownProps {
78
user?: HanzoUser
@@ -11,22 +12,6 @@ interface UserOrgDropdownProps {
1112
onSignOut?: () => void
1213
}
1314

14-
function Initials({ name, email }: { name?: string; email?: string }) {
15-
const seed = name || email || 'U'
16-
const initials = seed
17-
.split(/[\s@._-]/)
18-
.filter(Boolean)
19-
.slice(0, 2)
20-
.map((w) => w[0].toUpperCase())
21-
.join('')
22-
23-
return (
24-
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-800 text-[11px] font-semibold text-white/70">
25-
{initials}
26-
</span>
27-
)
28-
}
29-
3015
export function UserOrgDropdown({
3116
user,
3217
organizations = [],
@@ -56,15 +41,12 @@ export function UserOrgDropdown({
5641
onClick={() => setOpen((v) => !v)}
5742
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-white/[0.06] transition-colors"
5843
>
59-
{user.avatar ? (
60-
<img
61-
src={user.avatar}
62-
alt={user.name || user.email}
63-
className="h-7 w-7 rounded-full object-cover"
64-
/>
65-
) : (
66-
<Initials name={user.name} email={user.email} />
67-
)}
44+
<UserAvatar
45+
src={user.avatar}
46+
email={user.email}
47+
name={user.name}
48+
size={28}
49+
/>
6850
<div className="hidden flex-col items-start sm:flex">
6951
{user.name && (
7052
<span className="text-[12px] font-medium text-white/70 leading-none">{user.name}</span>

pkg/ui/src/navigation/hanzo-shell/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ export { HanzoHeader } from './HanzoHeader'
22
export { HanzoMark } from './HanzoMark'
33
export { AppSwitcher } from './AppSwitcher'
44
export { UserOrgDropdown } from './UserOrgDropdown'
5+
export { UserAvatar } from './UserAvatar'
6+
export type { UserAvatarProps } from './UserAvatar'
7+
export { BeamAvatar } from './BeamAvatar'
8+
export type { BeamAvatarProps } from './BeamAvatar'
59
export { useHanzoAuth } from './useHanzoAuth'
610
export { HanzoCommandPalette } from './HanzoCommandPalette'
711
export type { CommandItem as HanzoCommandItem, HanzoCommandPaletteProps } from './HanzoCommandPalette'

0 commit comments

Comments
 (0)