From 55e08e14b82d9338e56c1b707da9acad74064c5c Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Fri, 12 Dec 2025 23:08:54 -0500 Subject: [PATCH] consistent profile image, initials with background color as backup, better profile image editing --- backend/routes/items.js | 14 +- backend/routes/rentals.js | 4 +- frontend/src/components/Avatar.tsx | 146 ++++++++++++++++++ frontend/src/components/ChatWindow.tsx | 17 +- frontend/src/components/ItemReviews.tsx | 24 +-- frontend/src/components/ReviewModal.tsx | 22 +-- frontend/src/components/ReviewRenterModal.tsx | 22 +-- frontend/src/pages/ItemDetail.tsx | 24 +-- frontend/src/pages/Messages.tsx | 21 +-- frontend/src/pages/Profile.tsx | 69 ++++----- frontend/src/pages/PublicProfile.tsx | 17 +- 11 files changed, 196 insertions(+), 184 deletions(-) create mode 100644 frontend/src/components/Avatar.tsx diff --git a/backend/routes/items.js b/backend/routes/items.js index c4f49a3..5141ad5 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -91,7 +91,7 @@ router.get("/", async (req, res, next) => { { model: User, as: "owner", - attributes: ["id", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName", "imageFilename"], }, ], limit: parseInt(limit), @@ -175,7 +175,7 @@ router.get("/recommendations", authenticateToken, async (req, res, next) => { router.get('/:id/reviews', async (req, res, next) => { try { const { Rental, User } = require('../models'); - + const reviews = await Rental.findAll({ where: { itemId: req.params.id, @@ -185,10 +185,10 @@ router.get('/:id/reviews', async (req, res, next) => { itemReviewVisible: true }, include: [ - { - model: User, - as: 'renter', - attributes: ['id', 'firstName', 'lastName'] + { + model: User, + as: 'renter', + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -228,7 +228,7 @@ router.get("/:id", optionalAuth, async (req, res, next) => { { model: User, as: "owner", - attributes: ["id", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName", "imageFilename"], }, { model: User, diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index ad637c6..5b64883 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -66,7 +66,7 @@ router.get("/renting", authenticateToken, async (req, res) => { { model: User, as: "owner", - attributes: ["id", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName", "imageFilename"], }, ], order: [["createdAt", "DESC"]], @@ -94,7 +94,7 @@ router.get("/owning", authenticateToken, async (req, res) => { { model: User, as: "renter", - attributes: ["id", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName", "imageFilename"], }, ], order: [["createdAt", "DESC"]], diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..0fdbb95 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -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 = { + 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 = ({ + 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 ( +
+ +
+ ); + } + + 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 ( + {`${firstName} setImageError(true)} + /> + ); + } + + // Initials fallback + const bgColor = getAvatarColor(firstName, lastName, id); + const initials = getInitials(firstName, lastName); + + return ( +
+ {initials} +
+ ); +}; + +export default Avatar; diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 60b3b49..47bc9a6 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -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 = ({ {/* Header */}
- {recipient.imageFilename ? ( - {`${recipient.firstName} - ) : ( -
- -
- )} +
{recipient.firstName} {recipient.lastName} diff --git a/frontend/src/components/ItemReviews.tsx b/frontend/src/components/ItemReviews.tsx index 357e9cf..162e92c 100644 --- a/frontend/src/components/ItemReviews.tsx +++ b/frontend/src/components/ItemReviews.tsx @@ -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 = ({ itemId }) => { onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)} style={{ cursor: "pointer" }} > - {rental.renter?.imageFilename ? ( - {`${rental.renter.firstName} - ) : ( -
- -
- )} +
{rental.renter?.firstName} {rental.renter?.lastName} diff --git a/frontend/src/components/ReviewModal.tsx b/frontend/src/components/ReviewModal.tsx index e58f1ca..aee1148 100644 --- a/frontend/src/components/ReviewModal.tsx +++ b/frontend/src/components/ReviewModal.tsx @@ -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 = ({ {rental.owner && rental.item && (
- {rental.owner.imageFilename ? ( - {`${rental.owner.firstName} - ) : ( -
- {rental.owner.firstName[0]} - {rental.owner.lastName[0]} -
- )} +
{rental.owner.firstName} {rental.owner.lastName} diff --git a/frontend/src/components/ReviewRenterModal.tsx b/frontend/src/components/ReviewRenterModal.tsx index d5df546..999e3dd 100644 --- a/frontend/src/components/ReviewRenterModal.tsx +++ b/frontend/src/components/ReviewRenterModal.tsx @@ -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 = ({ {rental.renter && rental.item && (
- {rental.renter.imageFilename ? ( - {`${rental.renter.firstName} - ) : ( -
- {rental.renter.firstName[0]} - {rental.renter.lastName[0]} -
- )} +
{rental.renter.firstName} {rental.renter.lastName} diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index fb8b8f6..6ababcf 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -7,6 +7,7 @@ import { getPublicImageUrl } from "../services/uploadService"; import GoogleMapWithRadius from "../components/GoogleMapWithRadius"; import ItemReviews from "../components/ItemReviews"; import ConfirmationModal from "../components/ConfirmationModal"; +import Avatar from "../components/Avatar"; const ItemDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -480,28 +481,7 @@ const ItemDetail: React.FC = () => { onClick={() => navigate(`/users/${item.ownerId}`)} style={{ cursor: "pointer" }} > - {item.owner.imageFilename ? ( - {`${item.owner.firstName} - ) : ( -
- -
- )} + {item.owner.firstName} {item.owner.lastName} diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx index f34ccb1..f66fa93 100644 --- a/frontend/src/pages/Messages.tsx +++ b/frontend/src/pages/Messages.tsx @@ -4,6 +4,7 @@ import { messageAPI } from "../services/api"; import { useAuth } from "../contexts/AuthContext"; import { useSocket } from "../contexts/SocketContext"; import ChatWindow from "../components/ChatWindow"; +import Avatar from "../components/Avatar"; const Messages: React.FC = () => { const { user } = useAuth(); @@ -230,25 +231,7 @@ const Messages: React.FC = () => {
{/* Profile Picture */} - {conversation.partner.imageFilename ? ( - {`${conversation.partner.firstName} - ) : ( -
- -
- )} +
{/* User Name and Unread Badge */} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 858963b..f24eb86 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -8,6 +8,7 @@ import AvailabilitySettings from "../components/AvailabilitySettings"; import ReviewItemModal from "../components/ReviewModal"; import ReviewRenterModal from "../components/ReviewRenterModal"; import ReviewDetailsModal from "../components/ReviewDetailsModal"; +import Avatar from "../components/Avatar"; import { geocodingService, AddressComponents, @@ -313,6 +314,11 @@ const Profile: React.FC = () => { // Update preview to use the S3 URL setImagePreview(publicUrl); + + // Save imageFilename to database immediately + const response = await userAPI.updateProfile({ imageFilename: key }); + setProfileData(response.data); + updateUser(response.data); } catch (err: any) { console.error("Image upload error:", err); setError(err.message || "Failed to upload image"); @@ -747,55 +753,34 @@ const Profile: React.FC = () => {
- {imagePreview ? ( - Profile +
{profileData?.firstName} {profileData?.lastName}
-

@{profileData?.username}

diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 7d5d418..7c62efd 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -5,6 +5,7 @@ import { userAPI, itemAPI } from '../services/api'; import { getPublicImageUrl } from '../services/uploadService'; import { useAuth } from '../contexts/AuthContext'; import ChatWindow from '../components/ChatWindow'; +import Avatar from '../components/Avatar'; const PublicProfile: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -72,21 +73,7 @@ const PublicProfile: React.FC = () => {
- {user.imageFilename ? ( - {`${user.firstName} - ) : ( -
- -
- )} +

{user.firstName} {user.lastName}

{currentUser && currentUser.id !== user.id && (