Files
2026-07-13 13:32:57 +08:00

204 lines
4.8 KiB
TypeScript

import {
BoltIcon,
BuildingOffice2Icon,
CodeBracketSquareIcon,
FaceSmileIcon,
FireIcon,
GlobeAltIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import type { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
export const AvatarType = z.enum(["icon", "letters", "image"]);
export const AvatarData = z.discriminatedUnion("type", [
z.object({
type: z.literal(AvatarType.enum.icon),
name: z.string(),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.letters),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string(),
lastIconHex: z.string().optional(),
}),
]);
export type Avatar = z.infer<typeof AvatarData>;
export type IconAvatar = Extract<Avatar, { type: "icon" }>;
export type ImageAvatar = Extract<Avatar, { type: "image" }>;
export type LettersAvatar = Extract<Avatar, { type: "letters" }>;
export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avatar {
if (!json || typeof json !== "object") {
return defaultAvatar;
}
const parsed = AvatarData.safeParse(json);
if (!parsed.success) {
logger.error("Invalid org avatar", { json, error: parsed.error });
return defaultAvatar;
}
return parsed.data;
}
export function Avatar({
avatar,
size,
includePadding,
orgName,
}: {
avatar: Avatar;
/** Size in rems of the icon */
size: number;
includePadding?: boolean;
orgName: string;
}) {
switch (avatar.type) {
case "icon":
return <AvatarIcon avatar={avatar} size={size} includePadding={includePadding} />;
case "letters":
return (
<AvatarLetters
avatar={avatar}
size={size}
includePadding={includePadding}
orgName={orgName}
/>
);
case "image":
return <AvatarImage avatar={avatar} size={size} />;
}
}
export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
"hero:building-office-2": BuildingOffice2Icon,
"hero:rocket-launch": RocketLaunchIcon,
"hero:code-bracket-square": CodeBracketSquareIcon,
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
"hero:bolt": BoltIcon,
};
export const defaultAvatarColors = [
{ hex: "#878C99", name: "Gray" },
{ hex: "#713F12", name: "Brown" },
{ hex: "#F97316", name: "Orange" },
{ hex: "#EAB308", name: "Yellow" },
{ hex: "#22C55E", name: "Green" },
{ hex: "#3B82F6", name: "Blue" },
{ hex: "#6366F1", name: "Purple" },
{ hex: "#EC4899", name: "Pink" },
{ hex: "#F43F5E", name: "Red" },
];
// purple
export const defaultAvatarHex = defaultAvatarColors[6].hex;
export const defaultAvatar: Avatar = {
type: "letters",
hex: defaultAvatarHex,
};
function styleFromSize(size: number) {
return {
width: `${size}rem`,
height: `${size}rem`,
};
}
function AvatarLetters({
avatar,
size,
includePadding,
orgName,
}: {
avatar: LettersAvatar;
size: number;
includePadding?: boolean;
orgName: string;
}) {
const letters = orgName.slice(0, 2);
const style = {
backgroundColor: avatar.hex,
};
const scaleFactor = includePadding ? 0.8 : 1;
return (
<span
className="grid shrink-0 place-items-center overflow-hidden text-charcoal-750"
style={styleFromSize(size)}
>
{/* This is the square container */}
<span
className={cn(
"relative grid place-items-center overflow-hidden rounded-[10%] font-semibold",
includePadding ? "size-[80%]" : "size-full"
)}
style={style}
>
<span
className="font-bold leading-none"
style={{ fontSize: `${size * 0.6 * scaleFactor}rem` }}
>
{letters}
</span>
</span>
</span>
);
}
function AvatarIcon({
avatar,
size,
includePadding,
}: {
avatar: IconAvatar;
size: number;
includePadding?: boolean;
}) {
const style = {
color: avatar.hex,
};
const IconComponent = avatarIcons[avatar.name];
return (
<span className="grid aspect-square place-items-center" style={styleFromSize(size)}>
<IconComponent className={includePadding ? "size-[80%]" : "size-full"} style={style} />
</span>
);
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<img
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
/>
</span>
);
}