consistent profile image, initials with background color as backup, better profile image editing
This commit is contained in:
@@ -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),
|
||||
@@ -188,7 +188,7 @@ router.get('/:id/reviews', async (req, res, next) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'renter',
|
||||
attributes: ['id', 'firstName', 'lastName']
|
||||
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,
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
146
frontend/src/components/Avatar.tsx
Normal file
146
frontend/src/components/Avatar.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
<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>
|
||||
)}
|
||||
<Avatar user={item.owner} size="xs" className="me-2" />
|
||||
<span className="text-muted">
|
||||
{item.owner.firstName} {item.owner.lastName}
|
||||
</span>
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="d-flex w-100 justify-content-between align-items-start">
|
||||
<div className="d-flex align-items-center flex-grow-1">
|
||||
{/* Profile Picture */}
|
||||
{conversation.partner.imageFilename ? (
|
||||
<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>
|
||||
)}
|
||||
<Avatar user={conversation.partner} size="lg" className="me-3" />
|
||||
|
||||
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||
{/* User Name and Unread Badge */}
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="card-body">
|
||||
<div className="text-center">
|
||||
<div className="position-relative d-inline-block mb-3">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="rounded-circle"
|
||||
style={{
|
||||
width: "120px",
|
||||
height: "120px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
<Avatar
|
||||
user={user}
|
||||
size="xxl"
|
||||
imageUrl={imagePreview}
|
||||
/>
|
||||
<label
|
||||
htmlFor="imageFilenameOverview"
|
||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
width: "35px",
|
||||
height: "35px",
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
<input
|
||||
type="file"
|
||||
id="imageFilenameOverview"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="d-none"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5>
|
||||
{profileData?.firstName} {profileData?.lastName}
|
||||
</h5>
|
||||
<p className="text-muted">@{profileData?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="text-center mb-4">
|
||||
{user.imageFilename ? (
|
||||
<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>
|
||||
)}
|
||||
<Avatar user={user} size="xxxl" className="mb-3 mx-auto" />
|
||||
<h3>{user.firstName} {user.lastName}</h3>
|
||||
{currentUser && currentUser.id !== user.id && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user