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

@@ -91,7 +91,7 @@ router.get("/", async (req, res, next) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
}, },
], ],
limit: parseInt(limit), limit: parseInt(limit),
@@ -175,7 +175,7 @@ router.get("/recommendations", authenticateToken, async (req, res, next) => {
router.get('/:id/reviews', async (req, res, next) => { router.get('/:id/reviews', async (req, res, next) => {
try { try {
const { Rental, User } = require('../models'); const { Rental, User } = require('../models');
const reviews = await Rental.findAll({ const reviews = await Rental.findAll({
where: { where: {
itemId: req.params.id, itemId: req.params.id,
@@ -185,10 +185,10 @@ router.get('/:id/reviews', async (req, res, next) => {
itemReviewVisible: true itemReviewVisible: true
}, },
include: [ include: [
{ {
model: User, model: User,
as: 'renter', as: 'renter',
attributes: ['id', 'firstName', 'lastName'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -228,7 +228,7 @@ router.get("/:id", optionalAuth, async (req, res, next) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
}, },
{ {
model: User, model: User,

View File

@@ -66,7 +66,7 @@ router.get("/renting", authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
}, },
], ],
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],
@@ -94,7 +94,7 @@ router.get("/owning", authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: "renter", as: "renter",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
}, },
], ],
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],

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 { useAuth } from "../contexts/AuthContext";
import { useSocket } from "../contexts/SocketContext"; import { useSocket } from "../contexts/SocketContext";
import TypingIndicator from "./TypingIndicator"; import TypingIndicator from "./TypingIndicator";
import Avatar from "./Avatar";
interface ChatWindowProps { interface ChatWindowProps {
show: boolean; show: boolean;
@@ -462,21 +463,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* Header */} {/* Header */}
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0"> <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"> <div className="d-flex align-items-center">
{recipient.imageFilename ? ( <Avatar user={recipient} size="md" className="me-2" />
<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>
)}
<div> <div>
<h6 className="mb-0"> <h6 className="mb-0">
{recipient.firstName} {recipient.lastName} {recipient.firstName} {recipient.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Rental } from "../types"; import { Rental } from "../types";
import { itemAPI } from "../services/api"; import { itemAPI } from "../services/api";
import Avatar from "./Avatar";
interface ItemReviewsProps { interface ItemReviewsProps {
itemId: string; itemId: string;
@@ -85,28 +86,7 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)} onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
{rental.renter?.imageFilename ? ( <Avatar user={rental.renter || null} size="sm" />
<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>
)}
<div> <div>
<strong style={{ color: "#0d6efd" }}> <strong style={{ color: "#0d6efd" }}>
{rental.renter?.firstName} {rental.renter?.lastName} {rental.renter?.firstName} {rental.renter?.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { rentalAPI } from "../services/api"; import { rentalAPI } from "../services/api";
import { Rental } from "../types"; import { Rental } from "../types";
import SuccessModal from "./SuccessModal"; import SuccessModal from "./SuccessModal";
import Avatar from "./Avatar";
interface ReviewItemModalProps { interface ReviewItemModalProps {
show: boolean; show: boolean;
@@ -102,26 +103,7 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
{rental.owner && rental.item && ( {rental.owner && rental.item && (
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3"> <div className="d-flex justify-content-center mb-3">
{rental.owner.imageFilename ? ( <Avatar user={rental.owner} size="xl" />
<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>
)}
</div> </div>
<h6 className="mb-1"> <h6 className="mb-1">
{rental.owner.firstName} {rental.owner.lastName} {rental.owner.firstName} {rental.owner.lastName}

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { rentalAPI } from "../services/api"; import { rentalAPI } from "../services/api";
import { Rental } from "../types"; import { Rental } from "../types";
import SuccessModal from "./SuccessModal"; import SuccessModal from "./SuccessModal";
import Avatar from "./Avatar";
interface ReviewRenterModalProps { interface ReviewRenterModalProps {
show: boolean; show: boolean;
@@ -102,26 +103,7 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
{rental.renter && rental.item && ( {rental.renter && rental.item && (
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3"> <div className="d-flex justify-content-center mb-3">
{rental.renter.imageFilename ? ( <Avatar user={rental.renter} size="xl" />
<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>
)}
</div> </div>
<h6 className="mb-1"> <h6 className="mb-1">
{rental.renter.firstName} {rental.renter.lastName} {rental.renter.firstName} {rental.renter.lastName}

View File

@@ -7,6 +7,7 @@ import { getPublicImageUrl } from "../services/uploadService";
import GoogleMapWithRadius from "../components/GoogleMapWithRadius"; import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
import ItemReviews from "../components/ItemReviews"; import ItemReviews from "../components/ItemReviews";
import ConfirmationModal from "../components/ConfirmationModal"; import ConfirmationModal from "../components/ConfirmationModal";
import Avatar from "../components/Avatar";
const ItemDetail: React.FC = () => { const ItemDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -480,28 +481,7 @@ const ItemDetail: React.FC = () => {
onClick={() => navigate(`/users/${item.ownerId}`)} onClick={() => navigate(`/users/${item.ownerId}`)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
{item.owner.imageFilename ? ( <Avatar user={item.owner} size="xs" className="me-2" />
<img
src={item.owner.imageFilename}
alt={`${item.owner.firstName} ${item.owner.lastName}`}
className="rounded-circle me-2"
style={{
width: "30px",
height: "30px",
objectFit: "cover",
}}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
style={{ width: "30px", height: "30px" }}
>
<i
className="bi bi-person-fill text-white"
style={{ fontSize: "0.8rem" }}
></i>
</div>
)}
<span className="text-muted"> <span className="text-muted">
{item.owner.firstName} {item.owner.lastName} {item.owner.firstName} {item.owner.lastName}
</span> </span>

View File

@@ -4,6 +4,7 @@ import { messageAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useSocket } from "../contexts/SocketContext"; import { useSocket } from "../contexts/SocketContext";
import ChatWindow from "../components/ChatWindow"; import ChatWindow from "../components/ChatWindow";
import Avatar from "../components/Avatar";
const Messages: React.FC = () => { const Messages: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -230,25 +231,7 @@ const Messages: React.FC = () => {
<div className="d-flex w-100 justify-content-between align-items-start"> <div className="d-flex w-100 justify-content-between align-items-start">
<div className="d-flex align-items-center flex-grow-1"> <div className="d-flex align-items-center flex-grow-1">
{/* Profile Picture */} {/* Profile Picture */}
{conversation.partner.imageFilename ? ( <Avatar user={conversation.partner} size="lg" className="me-3" />
<img
src={conversation.partner.imageFilename}
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
className="rounded-circle me-3"
style={{
width: "50px",
height: "50px",
objectFit: "cover",
}}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
style={{ width: "50px", height: "50px" }}
>
<i className="bi bi-person-fill text-white"></i>
</div>
)}
<div className="flex-grow-1" style={{ minWidth: 0 }}> <div className="flex-grow-1" style={{ minWidth: 0 }}>
{/* User Name and Unread Badge */} {/* User Name and Unread Badge */}

View File

@@ -8,6 +8,7 @@ import AvailabilitySettings from "../components/AvailabilitySettings";
import ReviewItemModal from "../components/ReviewModal"; import ReviewItemModal from "../components/ReviewModal";
import ReviewRenterModal from "../components/ReviewRenterModal"; import ReviewRenterModal from "../components/ReviewRenterModal";
import ReviewDetailsModal from "../components/ReviewDetailsModal"; import ReviewDetailsModal from "../components/ReviewDetailsModal";
import Avatar from "../components/Avatar";
import { import {
geocodingService, geocodingService,
AddressComponents, AddressComponents,
@@ -313,6 +314,11 @@ const Profile: React.FC = () => {
// Update preview to use the S3 URL // Update preview to use the S3 URL
setImagePreview(publicUrl); setImagePreview(publicUrl);
// Save imageFilename to database immediately
const response = await userAPI.updateProfile({ imageFilename: key });
setProfileData(response.data);
updateUser(response.data);
} catch (err: any) { } catch (err: any) {
console.error("Image upload error:", err); console.error("Image upload error:", err);
setError(err.message || "Failed to upload image"); setError(err.message || "Failed to upload image");
@@ -747,55 +753,34 @@ const Profile: React.FC = () => {
<div className="card-body"> <div className="card-body">
<div className="text-center"> <div className="text-center">
<div className="position-relative d-inline-block mb-3"> <div className="position-relative d-inline-block mb-3">
{imagePreview ? ( <Avatar
<img user={user}
src={imagePreview} size="xxl"
alt="Profile" imageUrl={imagePreview}
className="rounded-circle" />
style={{ <label
width: "120px", htmlFor="imageFilenameOverview"
height: "120px", className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle d-flex align-items-center justify-content-center"
objectFit: "cover", style={{
}} width: "35px",
height: "35px",
}}
>
<i className="bi bi-pencil-fill"></i>
<input
type="file"
id="imageFilenameOverview"
accept="image/*"
onChange={handleImageChange}
className="d-none"
/> />
) : ( </label>
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
style={{ width: "120px", height: "120px" }}
>
<i
className="bi bi-person-fill text-white"
style={{ fontSize: "2.5rem" }}
></i>
</div>
)}
{editing && (
<label
htmlFor="imageFilenameOverview"
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
style={{
width: "35px",
height: "35px",
padding: "0",
}}
>
<i className="bi bi-camera-fill"></i>
<input
type="file"
id="imageFilenameOverview"
accept="image/*"
onChange={handleImageChange}
className="d-none"
/>
</label>
)}
</div> </div>
<div> <div>
<h5> <h5>
{profileData?.firstName} {profileData?.lastName} {profileData?.firstName} {profileData?.lastName}
</h5> </h5>
<p className="text-muted">@{profileData?.username}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { userAPI, itemAPI } from '../services/api';
import { getPublicImageUrl } from '../services/uploadService'; import { getPublicImageUrl } from '../services/uploadService';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ChatWindow from '../components/ChatWindow'; import ChatWindow from '../components/ChatWindow';
import Avatar from '../components/Avatar';
const PublicProfile: React.FC = () => { const PublicProfile: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -72,21 +73,7 @@ const PublicProfile: React.FC = () => {
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
<div className="text-center mb-4"> <div className="text-center mb-4">
{user.imageFilename ? ( <Avatar user={user} size="xxxl" className="mb-3 mx-auto" />
<img
src={user.imageFilename}
alt={`${user.firstName} ${user.lastName}`}
className="rounded-circle mb-3"
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center mb-3 mx-auto"
style={{ width: '150px', height: '150px' }}
>
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
</div>
)}
<h3>{user.firstName} {user.lastName}</h3> <h3>{user.firstName} {user.lastName}</h3>
{currentUser && currentUser.id !== user.id && ( {currentUser && currentUser.id !== user.id && (
<button <button