From a40e32978a0dcacce49dbf1ee9c9b70a8c4bfa65 Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Fri, 30 Jan 2026 15:24:09 +0100 Subject: [PATCH] feat: add Facehash component for deterministic avatar generation and update UserAvatar to use it --- apps/infoalloggi/src/components/etichette.tsx | 18 +- .../src/components/facehash/facehash.tsx | 352 ++++++++++++++++++ .../src/components/facehash/faces.tsx | 195 ++++++++++ .../src/components/user_avatar.tsx | 55 ++- apps/infoalloggi/src/lib/color.ts | 20 + apps/infoalloggi/src/pages/test.tsx | 34 +- 6 files changed, 622 insertions(+), 52 deletions(-) create mode 100644 apps/infoalloggi/src/components/facehash/facehash.tsx create mode 100644 apps/infoalloggi/src/components/facehash/faces.tsx create mode 100644 apps/infoalloggi/src/lib/color.ts diff --git a/apps/infoalloggi/src/components/etichette.tsx b/apps/infoalloggi/src/components/etichette.tsx index e94b7c1..1a31997 100644 --- a/apps/infoalloggi/src/components/etichette.tsx +++ b/apps/infoalloggi/src/components/etichette.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from "~/components/ui/select"; import { Separator } from "~/components/ui/separator"; +import { colorIsDarkSimple } from "~/lib/color"; import { cn } from "~/lib/utils"; import type { ChatsChatid } from "~/schemas/public/Chats"; import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette"; @@ -127,7 +128,7 @@ export const EtichetteModal = ({ setSelected(parseInt(value) as EtichetteIdEtichetta) } > - + @@ -181,18 +182,3 @@ export const Etichetta = ({ ); }; - -function hexToRgb(hex: string): { r: number; g: number; b: number } { - const bigint = parseInt(hex.slice(1), 16); - return { - b: bigint & 255, - g: (bigint >> 8) & 255, - r: (bigint >> 16) & 255, - }; -} - -export function colorIsDarkSimple(color: string) { - if (color === "#FFFFFF") return false; - const { r, g, b } = hexToRgb(color); - return r * 0.299 + g * 0.587 + b * 0.114 <= 186; -} diff --git a/apps/infoalloggi/src/components/facehash/facehash.tsx b/apps/infoalloggi/src/components/facehash/facehash.tsx new file mode 100644 index 0000000..260efa3 --- /dev/null +++ b/apps/infoalloggi/src/components/facehash/facehash.tsx @@ -0,0 +1,352 @@ +import * as React from "react"; +import { isColorDark } from "~/lib/color"; +import { FACES } from "./faces"; + +// from https://github.com/cossistantcom/cossistant/tree/main/packages/facehash + +export type Intensity3D = "none" | "subtle" | "medium" | "dramatic"; +export type Variant = "gradient" | "solid"; + +export interface FacehashProps + extends Omit, "children"> { + /** + * String to generate a deterministic face from. + * Same string always produces the same face. + */ + name: string; + + /** + * Size in pixels or CSS units. + * @default 40 + */ + size?: number | string; + + /** + * Background style. + * - "gradient": Adds gradient overlay (default) + * - "solid": Plain background color + * @default "gradient" + */ + variant?: Variant; + + /** + * 3D effect intensity. + * @default "dramatic" + */ + intensity3d?: Intensity3D; + + /** + * Enable hover interaction. + * When true, face "looks straight" on hover. + * @default true + */ + interactive?: boolean; + + /** + * Show first letter of name below the face. + * @default true + */ + showInitial?: boolean; + + initialsCount?: number; + + /** + * Hex color array for inline styles. + * Use this OR colorClasses, not both. + */ + colors?: string[]; + + /** + * Tailwind class array for background colors. + * Example: ["bg-pink-500 dark:bg-pink-600", "bg-blue-500 dark:bg-blue-600"] + * Use this OR colors, not both. + */ + colorClasses?: string[]; + + /** + * Custom gradient overlay class (Tailwind). + * When provided, replaces the default pure CSS gradient. + * Only used when variant="gradient". + */ + gradientOverlayClass?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const INTENSITY_PRESETS = { + none: { + rotateRange: 0, + translateZ: 0, + perspective: "none", + }, + subtle: { + rotateRange: 5, + translateZ: 4, + perspective: "800px", + }, + medium: { + rotateRange: 10, + translateZ: 8, + perspective: "500px", + }, + dramatic: { + rotateRange: 15, + translateZ: 12, + perspective: "300px", + }, +} as const; + +const SPHERE_POSITIONS = [ + { x: -1, y: 1 }, // down-right + { x: 1, y: 1 }, // up-right + { x: 1, y: 0 }, // up + { x: 0, y: 1 }, // right + { x: -1, y: 0 }, // down + { x: 0, y: 0 }, // center + { x: 0, y: -1 }, // left + { x: -1, y: -1 }, // down-left + { x: 1, y: -1 }, // up-left +] as const; + +// Default gradient as pure CSS (works without Tailwind) +// Matches: bg-[radial-gradient(ellipse_100%_100%_at_50%_50%,_COLOR_0%,_transparent_60%)] +// Light mode: white glow in center, Dark mode: dark overlay in center +const DEFAULT_GRADIENT_STYLE: React.CSSProperties = { + background: + "radial-gradient(ellipse 100% 100% at 50% 50%, rgba(255,255,255,0.15) 0%, transparent 60%)", +}; + +/** + * Facehash - Deterministic avatar faces from any string. + * + * @example + * ```tsx + * // With Tailwind classes + * + * + * // With hex colors + * + * + * // Plain color (no gradient) + * + * ``` + */ +export const Facehash = React.forwardRef( + ( + { + name, + size = 40, + variant = "gradient", + intensity3d = "dramatic", + interactive = true, + showInitial = true, + initialsCount = 1, + colors, + colorClasses, + gradientOverlayClass, + className, + style, + onMouseEnter, + onMouseLeave, + ...props + }, + ref, + ) => { + const [isHovered, setIsHovered] = React.useState(false); + + // Generate deterministic values from name + const { FaceComponent, colorIndex, rotation } = React.useMemo(() => { + const hash = stringHash(name); + const faceIndex = hash % FACES.length; + const colorsLength = colorClasses?.length ?? colors?.length ?? 1; + const _colorIndex = hash % colorsLength; + const positionIndex = hash % SPHERE_POSITIONS.length; + const position = SPHERE_POSITIONS[positionIndex] ?? { x: 0, y: 0 }; + + return { + FaceComponent: FACES[faceIndex] ?? FACES[0], + colorIndex: _colorIndex, + rotation: position, + }; + }, [name, colors?.length, colorClasses?.length]); + + // Get intensity preset + const preset = INTENSITY_PRESETS[intensity3d]; + + // Calculate 3D transform + const transform = React.useMemo(() => { + if (intensity3d === "none") { + return; + } + + const rotateX = + isHovered && interactive ? 0 : rotation.x * preset.rotateRange; + const rotateY = + isHovered && interactive ? 0 : rotation.y * preset.rotateRange; + + return `rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${preset.translateZ}px)`; + }, [intensity3d, isHovered, interactive, rotation, preset]); + + // Size style + const sizeValue = typeof size === "number" ? `${size}px` : size; + + // Initial letter + const initial = name.trim().substring(0, initialsCount).toUpperCase(); + + // Background: either hex color (inline) or class + const bgColorClass = colorClasses?.[colorIndex]; + const bgColorHex = colors?.[colorIndex]; + + // Event handlers + const handleMouseEnter = React.useCallback( + (e: React.MouseEvent) => { + if (interactive) { + setIsHovered(true); + } + onMouseEnter?.(e); + }, + [interactive, onMouseEnter], + ); + + const handleMouseLeave = React.useCallback( + (e: React.MouseEvent) => { + if (interactive) { + setIsHovered(false); + } + onMouseLeave?.(e); + }, + [interactive, onMouseLeave], + ); + + return ( + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Hover effect is purely cosmetic + // biome-ignore lint/a11y/noStaticElementInteractions: This is a decorative avatar component +
+ {/* Gradient overlay */} + {variant === "gradient" && ( +
+ )} + + {/* Face container with 3D transform */} +
+ {/* Face SVG */} + + + {/* Initial letter */} + {showInitial && ( + + {initial} + + )} +
+
+ ); + }, +); + +Facehash.displayName = "Facehash"; + +/** + * Generates a consistent numeric hash from a string. + * Used to deterministically select faces and colors for avatars. + * + * @param str - The input string to hash + * @returns A positive 32-bit integer hash + */ +function stringHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash &= hash; // Convert to 32bit integer + } + return Math.abs(hash); +} diff --git a/apps/infoalloggi/src/components/facehash/faces.tsx b/apps/infoalloggi/src/components/facehash/faces.tsx new file mode 100644 index 0000000..7f3d2a9 --- /dev/null +++ b/apps/infoalloggi/src/components/facehash/faces.tsx @@ -0,0 +1,195 @@ +import type * as React from "react"; + +export type FaceProps = { + className?: string; + style?: React.CSSProperties; +}; + +/** + * Round eyes face - simple circular eyes + */ +export const RoundFace: React.FC = ({ className, style }) => ( + +); + +/** + * Cross eyes face - X-shaped eyes + */ +export const CrossFace: React.FC = ({ className, style }) => ( + +); + +/** + * Line eyes face - horizontal line eyes + */ +export const LineFace: React.FC = ({ className, style }) => ( + +); + +/** + * Curved eyes face - sleepy/happy curved eyes + */ +export const CurvedFace: React.FC = ({ className, style }) => ( + +); + +/** + * Square eyes face - robot/neutral style + */ +export const SquareFace: React.FC = ({ className, style }) => ( + +); + +/** + * Star eyes face - excited/dazzled + */ +export const StarFace: React.FC = ({ className, style }) => ( + +); + +/** + * Wink face - one round eye, one closed + */ +export const WinkFace: React.FC = ({ className, style }) => ( + +); + +/** + * All available face components + */ +export const FACES = [ + RoundFace, + CrossFace, + LineFace, + CurvedFace, + SquareFace, + StarFace, + WinkFace, +] as const; + +//export type FaceComponent = (typeof FACES)[number]; diff --git a/apps/infoalloggi/src/components/user_avatar.tsx b/apps/infoalloggi/src/components/user_avatar.tsx index 1f786c4..efae398 100644 --- a/apps/infoalloggi/src/components/user_avatar.tsx +++ b/apps/infoalloggi/src/components/user_avatar.tsx @@ -1,7 +1,6 @@ -import { colorIsDarkSimple } from "~/components/etichette"; -import { Avatar, AvatarFallback } from "~/components/ui/avatar"; import { cn, getHexColorFromString, getInitials } from "~/lib/utils"; import type { UsersId } from "~/schemas/public/Users"; +import { Facehash } from "./facehash/facehash"; const colorOptions = new Map(); @@ -14,8 +13,8 @@ export const getUserColor = (str: string) => { colorOptions.set(str, usercolor); return usercolor; }; - -export const UserAvatar = ({ +/* +export const UserAvatarOld = ({ userId, username, className, @@ -39,3 +38,51 @@ export const UserAvatar = ({ ); }; +*/ +export const UserAvatar = ({ + userId, + username, + className, +}: { + userId: UsersId; + username?: string; + className?: string; +}) => { + const value = `${username && getInitials(username)}${userId}`; + return ( + + ); +}; diff --git a/apps/infoalloggi/src/lib/color.ts b/apps/infoalloggi/src/lib/color.ts new file mode 100644 index 0000000..2da0d6b --- /dev/null +++ b/apps/infoalloggi/src/lib/color.ts @@ -0,0 +1,20 @@ +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const bigint = parseInt(hex.slice(1), 16); + return { + b: bigint & 255, + g: (bigint >> 8) & 255, + r: (bigint >> 16) & 255, + }; +} + +export function colorIsDarkSimple(color: string) { + if (color === "#FFFFFF") return false; + const { r, g, b } = hexToRgb(color); + return r * 0.299 + g * 0.587 + b * 0.114 <= 186; +} + +export function isColorDark(hex: string, cutoff = 186) { + if (hex === "#FFFFFF") return false; + const { r, g, b } = hexToRgb(hex); + return r * 0.299 + g * 0.587 + b * 0.114 <= cutoff; +} diff --git a/apps/infoalloggi/src/pages/test.tsx b/apps/infoalloggi/src/pages/test.tsx index e670899..d542397 100644 --- a/apps/infoalloggi/src/pages/test.tsx +++ b/apps/infoalloggi/src/pages/test.tsx @@ -1,35 +1,5 @@ -import { - ExpandableScreen, - ExpandableScreenContent, - ExpandableScreenTrigger, -} from "~/components/expandable_screen"; -import { Button } from "~/components/ui/button"; -import type { NextPageWithLayout } from "./_app"; - -const Test: NextPageWithLayout = () => { - return ( -
- -
- - - -
- - -
-

- Full Screen Content -

-
-
-
-
- ); +const Test = () => { + return
test
; }; export default Test;