consistent profile image, initials with background color as backup, better profile image editing

This commit is contained in:
jackiettran
2025-12-12 23:08:54 -05:00
parent 3f319bfdd0
commit 55e08e14b8
11 changed files with 196 additions and 184 deletions

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect } from "react";
import { getPublicImageUrl } from "../services/uploadService";
interface AvatarUser {
id?: string;
firstName: string;
lastName: string;
imageFilename?: string;
}
interface AvatarProps {
user: AvatarUser | null;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl";
sizePx?: number;
className?: string;
onClick?: () => void;
imageUrl?: string | null;
}
const sizeMap: Record<string, number> = {
xs: 30,
sm: 32,
md: 35,
lg: 50,
xl: 60,
xxl: 120,
xxxl: 150,
};
const colors = [
"#6366f1", // Indigo
"#8b5cf6", // Violet
"#ec4899", // Pink
"#ef4444", // Red
"#f97316", // Orange
"#eab308", // Yellow
"#22c55e", // Green
"#14b8a6", // Teal
"#06b6d4", // Cyan
"#3b82f6", // Blue
];
const getAvatarColor = (
firstName: string,
lastName: string,
userId?: string
): string => {
const input = userId || `${firstName}${lastName}`;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = input.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
const getInitials = (firstName: string, lastName: string): string => {
const firstInitial = firstName?.charAt(0)?.toUpperCase() || "";
const lastInitial = lastName?.charAt(0)?.toUpperCase() || "";
return `${firstInitial}${lastInitial}` || "?";
};
const Avatar: React.FC<AvatarProps> = ({
user,
size = "md",
sizePx,
className = "",
onClick,
imageUrl: directImageUrl,
}) => {
const [imageError, setImageError] = useState(false);
// Reset error state when image URL changes
useEffect(() => {
setImageError(false);
}, [directImageUrl, user?.imageFilename]);
const pixelSize = sizePx || sizeMap[size];
const fontSize = Math.max(pixelSize * 0.4, 12);
// Handle missing user - show generic placeholder
if (!user) {
return (
<div
className={`rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white ${className}`}
style={{
width: pixelSize,
height: pixelSize,
fontSize: fontSize,
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
role="img"
aria-label="User avatar"
>
<i className="bi bi-person-fill"></i>
</div>
);
}
const { firstName, lastName, imageFilename, id } = user;
// Use direct imageUrl if provided, otherwise construct from imageFilename
const imageUrl = directImageUrl || (imageFilename ? getPublicImageUrl(imageFilename) : null);
const hasValidImage = imageUrl && !imageError;
if (hasValidImage) {
return (
<img
src={imageUrl}
alt={`${firstName} ${lastName}`}
className={`rounded-circle ${className}`}
style={{
width: pixelSize,
height: pixelSize,
objectFit: "cover",
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
onError={() => setImageError(true)}
/>
);
}
// Initials fallback
const bgColor = getAvatarColor(firstName, lastName, id);
const initials = getInitials(firstName, lastName);
return (
<div
className={`rounded-circle d-flex align-items-center justify-content-center text-white fw-bold ${className}`}
style={{
width: pixelSize,
height: pixelSize,
backgroundColor: bgColor,
fontSize: fontSize,
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
role="img"
aria-label={`${firstName} ${lastName}'s avatar`}
>
{initials}
</div>
);
};
export default Avatar;

View File

@@ -11,6 +11,7 @@ import { User, Message } from "../types";
import { useAuth } from "../contexts/AuthContext";
import { useSocket } from "../contexts/SocketContext";
import TypingIndicator from "./TypingIndicator";
import Avatar from "./Avatar";
interface ChatWindowProps {
show: boolean;
@@ -462,21 +463,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* Header */}
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
<div className="d-flex align-items-center">
{recipient.imageFilename ? (
<img
src={recipient.imageFilename}
alt={`${recipient.firstName} ${recipient.lastName}`}
className="rounded-circle me-2"
style={{ width: "35px", height: "35px", objectFit: "cover" }}
/>
) : (
<div
className="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-2"
style={{ width: "35px", height: "35px" }}
>
<i className="bi bi-person-fill text-white"></i>
</div>
)}
<Avatar user={recipient} size="md" className="me-2" />
<div>
<h6 className="mb-0">
{recipient.firstName} {recipient.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Rental } from "../types";
import { itemAPI } from "../services/api";
import Avatar from "./Avatar";
interface ItemReviewsProps {
itemId: string;
@@ -85,28 +86,7 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
style={{ cursor: "pointer" }}
>
{rental.renter?.imageFilename ? (
<img
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{
width: "32px",
height: "32px",
objectFit: "cover",
}}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
style={{ width: "32px", height: "32px" }}
>
<i
className="bi bi-person-fill text-white"
style={{ fontSize: "0.8rem" }}
></i>
</div>
)}
<Avatar user={rental.renter || null} size="sm" />
<div>
<strong style={{ color: "#0d6efd" }}>
{rental.renter?.firstName} {rental.renter?.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { rentalAPI } from "../services/api";
import { Rental } from "../types";
import SuccessModal from "./SuccessModal";
import Avatar from "./Avatar";
interface ReviewItemModalProps {
show: boolean;
@@ -102,26 +103,7 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
{rental.owner && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.owner.imageFilename ? (
<img
src={rental.owner.imageFilename}
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
className="rounded-circle"
style={{
width: "60px",
height: "60px",
objectFit: "cover",
}}
/>
) : (
<div
className="rounded-circle bg-primary d-flex align-items-center justify-content-center text-white fw-bold"
style={{ width: "60px", height: "60px" }}
>
{rental.owner.firstName[0]}
{rental.owner.lastName[0]}
</div>
)}
<Avatar user={rental.owner} size="xl" />
</div>
<h6 className="mb-1">
{rental.owner.firstName} {rental.owner.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { rentalAPI } from "../services/api";
import { Rental } from "../types";
import SuccessModal from "./SuccessModal";
import Avatar from "./Avatar";
interface ReviewRenterModalProps {
show: boolean;
@@ -102,26 +103,7 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
{rental.renter && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.renter.imageFilename ? (
<img
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{
width: "60px",
height: "60px",
objectFit: "cover",
}}
/>
) : (
<div
className="rounded-circle bg-primary d-flex align-items-center justify-content-center text-white fw-bold"
style={{ width: "60px", height: "60px" }}
>
{rental.renter.firstName[0]}
{rental.renter.lastName[0]}
</div>
)}
<Avatar user={rental.renter} size="xl" />
</div>
<h6 className="mb-1">
{rental.renter.firstName} {rental.renter.lastName}