204 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|