feat: add solid colors for avatar

This commit is contained in:
Marc Fernandez
2025-12-31 08:54:10 +01:00
parent 58b75205af
commit eb14a68bdd
4 changed files with 92 additions and 22 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
# Development environment
.devcontainer
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, User } from "lucide-react";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,6 +27,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
const availableAvatars = useMemo(() => {
if (gravatarHash === null) return randomImages;
@@ -274,16 +276,8 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
defaultValue={getAvatarType(field.value)}
value={getAvatarType(field.value)}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -370,6 +364,34 @@ export const ProfileForm = () => {
/>
</FormLabel>
</FormItem>
<FormItem key="color-avatar">
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
<FormControl>
<RadioGroupItem
value="color"
className="sr-only"
/>
</FormControl>
<div
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
style={{
backgroundColor: isSolidColorAvatar(field.value) ? field.value : undefined,
}}
onClick={() => colorInputRef.current?.click()}
>
{!isSolidColorAvatar(field.value) && (
<Palette className="h-5 w-5 text-muted-foreground" />
)}
</div>
<input
ref={colorInputRef}
type="color"
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
value={field.value}
onChange={field.onChange}
/>
</FormLabel>
</FormItem>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
+28 -9
View File
@@ -1,6 +1,6 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { isSolidColorAvatar } from "@/lib/avatar-utils";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
src?: string | null;
}
>(({ className, src, ...props }, ref) => {
if (isSolidColorAvatar(src)) {
return (
<div
key={`solid-${src}`}
ref={ref}
className={cn("aspect-square h-full w-full rounded-full", className)}
style={{
backgroundColor: src,
}}
{...props}
/>
);
}
return (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
src={src ?? ""}
{...props}
/>
);
});
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
+26
View File
@@ -0,0 +1,26 @@
/**
* Checks if the given avatar value represents a solid color in hexadecimal format.
*
* @param value Avatar value to check.
*
* @return True if the avatar is a solid color, false otherwise.
*/
export function isSolidColorAvatar(value?: string | null) {
return (value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) || value?.startsWith("color:") || false;
};
/**
* Gets the avatar type for form selection (RadioGroup value).
*
* @param value Avatar value.
*
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
*/
export function getAvatarType(value?: string | null) {
if (!value) return "";
if (value.startsWith("data:")) return "upload";
if (isSolidColorAvatar(value)) return "color";
return value;
};