feat(ui): unify avatars across navbar, profiles, event/match chat
- Add reusable Avatar with fallback, status dot, ring - Replace <img> uses in Navbar, Profile, PublicProfile - Use Avatar in MatchChatPage and EventChatPage messages and sidebars - Fix own-message detection for snake_case payloads
This commit is contained in:
71
frontend/src/components/common/Avatar.jsx
Normal file
71
frontend/src/components/common/Avatar.jsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
online: 'bg-green-500',
|
||||||
|
offline: 'bg-gray-400',
|
||||||
|
busy: 'bg-yellow-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Avatar = ({
|
||||||
|
src,
|
||||||
|
username = '',
|
||||||
|
alt,
|
||||||
|
size = 32, // number (px) or string with Tailwind classes
|
||||||
|
className = '', // applied to <img>
|
||||||
|
containerClassName = '', // applied to wrapper
|
||||||
|
rounded = true,
|
||||||
|
ring = false,
|
||||||
|
ringColor = 'ring-white',
|
||||||
|
status = null, // 'online' | 'offline' | 'busy' | null
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const [imgSrc, setImgSrc] = React.useState(src || '');
|
||||||
|
|
||||||
|
const isNumericSize = typeof size === 'number';
|
||||||
|
const style = isNumericSize ? { width: size, height: size } : undefined;
|
||||||
|
const sizeClass = isNumericSize ? '' : size; // e.g. 'w-8 h-8'
|
||||||
|
|
||||||
|
const fallback = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
|
||||||
|
username || 'user'
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const handleError = (e) => {
|
||||||
|
if (e.currentTarget.src !== fallback) {
|
||||||
|
setImgSrc(fallback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const img = (
|
||||||
|
<img
|
||||||
|
src={imgSrc || fallback}
|
||||||
|
alt={alt || username || 'avatar'}
|
||||||
|
className={`${sizeClass} ${rounded ? 'rounded-full' : ''} bg-gray-100 object-cover ${
|
||||||
|
ring ? `ring-2 ${ringColor}` : ''
|
||||||
|
} ${className}`}
|
||||||
|
style={style}
|
||||||
|
loading="lazy"
|
||||||
|
onError={handleError}
|
||||||
|
title={title || username}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no status, no need for wrapper; keep DOM minimal
|
||||||
|
if (!status) {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotColor = STATUS_COLORS[status] || STATUS_COLORS.offline;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`relative inline-block ${containerClassName}`} onClick={onClick}>
|
||||||
|
{img}
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-0 right-0 block h-3 w-3 ${dotColor} rounded-full ring-2 ring-white`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { Video, LogOut, User, History, Users } from 'lucide-react';
|
import { Video, LogOut, User, History, Users } from 'lucide-react';
|
||||||
|
import Avatar from '../common/Avatar';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { matchesAPI } from '../../services/api';
|
import { matchesAPI } from '../../services/api';
|
||||||
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
|
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
|
||||||
@@ -99,11 +100,7 @@ const Navbar = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<img
|
<Avatar src={user?.avatar} username={user.username} size={32} />
|
||||||
src={user.avatar}
|
|
||||||
alt={user.username}
|
|
||||||
className="w-8 h-8 rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">{user.username}</span>
|
<span className="text-sm font-medium text-gray-700">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter,
|
|||||||
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
||||||
import { eventsAPI, heatsAPI, matchesAPI } from '../services/api';
|
import { eventsAPI, heatsAPI, matchesAPI } from '../services/api';
|
||||||
import HeatsBanner from '../components/heats/HeatsBanner';
|
import HeatsBanner from '../components/heats/HeatsBanner';
|
||||||
|
import Avatar from '../components/common/Avatar';
|
||||||
|
|
||||||
const EventChatPage = () => {
|
const EventChatPage = () => {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -536,20 +537,13 @@ const EventChatPage = () => {
|
|||||||
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
|
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
<div className="relative flex-shrink-0">
|
<Avatar
|
||||||
<img
|
|
||||||
src={displayUser.avatar}
|
src={displayUser.avatar}
|
||||||
alt={displayUser.username}
|
username={displayUser.username}
|
||||||
className="w-8 h-8 rounded-full"
|
size={32}
|
||||||
|
status={displayUser.isOnline ? 'online' : 'offline'}
|
||||||
|
title={displayUser.username}
|
||||||
/>
|
/>
|
||||||
{/* Online/Offline indicator */}
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-gray-50 ${
|
|
||||||
displayUser.isOnline ? 'bg-green-500' : 'bg-gray-400'
|
|
||||||
}`}
|
|
||||||
title={displayUser.isOnline ? 'Online' : 'Offline'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={`text-sm font-medium truncate ${
|
<p className={`text-sm font-medium truncate ${
|
||||||
displayUser.isOnline ? 'text-gray-900' : 'text-gray-500'
|
displayUser.isOnline ? 'text-gray-900' : 'text-gray-500'
|
||||||
@@ -611,17 +605,18 @@ const EventChatPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const isOwnMessage = message.userId === user.id;
|
const isOwnMessage = (message.userId ?? message.user_id) === user.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||||
<img
|
<Avatar
|
||||||
src={message.avatar}
|
src={message.avatar}
|
||||||
alt={message.username}
|
username={message.username}
|
||||||
className="w-8 h-8 rounded-full"
|
size={32}
|
||||||
|
title={message.username}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline space-x-2 mb-1">
|
<div className="flex items-baseline space-x-2 mb-1">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { connectSocket, getSocket } from '../services/socket';
|
|||||||
import { useWebRTC } from '../hooks/useWebRTC';
|
import { useWebRTC } from '../hooks/useWebRTC';
|
||||||
import { detectWebRTCSupport } from '../utils/webrtcDetection';
|
import { detectWebRTCSupport } from '../utils/webrtcDetection';
|
||||||
import WebRTCWarning from '../components/WebRTCWarning';
|
import WebRTCWarning from '../components/WebRTCWarning';
|
||||||
|
import Avatar from '../components/common/Avatar';
|
||||||
|
|
||||||
const MatchChatPage = () => {
|
const MatchChatPage = () => {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -286,10 +287,12 @@ const MatchChatPage = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link to={`/${partner.username}`} className="flex-shrink-0">
|
<Link to={`/${partner.username}`} className="flex-shrink-0">
|
||||||
<img
|
<Avatar
|
||||||
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
src={partner.avatar}
|
||||||
alt={partner.username}
|
username={partner.username}
|
||||||
className="w-12 h-12 rounded-full border-2 border-white hover:ring-2 hover:ring-white hover:ring-offset-2 hover:ring-offset-primary-600 transition-all"
|
size={48}
|
||||||
|
className="border-2 border-white transition-all hover:ring-2 hover:ring-white hover:ring-offset-2 hover:ring-offset-primary-600"
|
||||||
|
title={partner.username}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
@@ -355,17 +358,18 @@ const MatchChatPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const isOwnMessage = message.userId === user.id;
|
const isOwnMessage = (message.userId ?? message.user_id) === user.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||||
<img
|
<Avatar
|
||||||
src={message.avatar}
|
src={message.avatar || (isOwnMessage ? user?.avatar : partner?.avatar)}
|
||||||
alt={message.username}
|
username={message.username || (isOwnMessage ? user?.username : partner?.username)}
|
||||||
className="w-8 h-8 rounded-full"
|
size={32}
|
||||||
|
title={message.username || (isOwnMessage ? user?.username : partner?.username)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline space-x-2 mb-1">
|
<div className="flex items-baseline space-x-2 mb-1">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authAPI } from '../services/api';
|
|||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash, Youtube, Instagram, Facebook, MapPin, Globe } from 'lucide-react';
|
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash, Youtube, Instagram, Facebook, MapPin, Globe } from 'lucide-react';
|
||||||
import { COUNTRIES } from '../data/countries';
|
import { COUNTRIES } from '../data/countries';
|
||||||
|
import Avatar from '../components/common/Avatar';
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
@@ -138,10 +139,13 @@ const ProfilePage = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img
|
<Avatar
|
||||||
src={user?.avatar}
|
src={user?.avatar}
|
||||||
alt={user?.username}
|
username={user?.username}
|
||||||
className="w-20 h-20 rounded-full"
|
size={80}
|
||||||
|
ring
|
||||||
|
ringColor="ring-white"
|
||||||
|
title={user?.username}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
|
|||||||
import { authAPI, ratingsAPI } from '../services/api';
|
import { authAPI, ratingsAPI } from '../services/api';
|
||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { User, MapPin, Globe, Hash, Youtube, Instagram, Facebook, Award, Users, Star, Calendar, Loader2, AlertCircle, ThumbsUp } from 'lucide-react';
|
import { User, MapPin, Globe, Hash, Youtube, Instagram, Facebook, Award, Users, Star, Calendar, Loader2, AlertCircle, ThumbsUp } from 'lucide-react';
|
||||||
|
import Avatar from '../components/common/Avatar';
|
||||||
|
|
||||||
const PublicProfilePage = () => {
|
const PublicProfilePage = () => {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
@@ -86,10 +87,12 @@ const PublicProfilePage = () => {
|
|||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 mb-6">
|
<div className="bg-white rounded-lg shadow-sm p-8 mb-6">
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
<img
|
<Avatar
|
||||||
src={profile.avatar}
|
src={profile.avatar}
|
||||||
alt={profile.username}
|
username={profile.username}
|
||||||
className="w-32 h-32 rounded-full"
|
size={128}
|
||||||
|
className="transition-all hover:ring-2 hover:ring-primary-500"
|
||||||
|
title={profile.username}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-1">
|
<h1 className="text-3xl font-bold text-gray-900 mb-1">
|
||||||
@@ -263,10 +266,12 @@ const PublicProfilePage = () => {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Rater Info */}
|
{/* Rater Info */}
|
||||||
<Link to={`/${rating.rater.username}`} className="flex-shrink-0">
|
<Link to={`/${rating.rater.username}`} className="flex-shrink-0">
|
||||||
<img
|
<Avatar
|
||||||
src={rating.rater.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${rating.rater.username}`}
|
src={rating.rater.avatar}
|
||||||
alt={rating.rater.username}
|
username={rating.rater.username}
|
||||||
className="w-12 h-12 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
size={48}
|
||||||
|
className="hover:ring-2 hover:ring-primary-500 transition-all"
|
||||||
|
title={rating.rater.username}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user