mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
merged feat/community
This commit is contained in:
commit
c0715e071f
11 files changed
+287
-28
No files matched your search
@@ -39,3 +39,4 @@ Thumbs.db
|
||||
.env.*
|
||||
!.env.example
|
||||
frontend/.env*
|
||||
backend/django_cache/
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-16 18:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0011_alter_flavor_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='following',
|
||||
field=models.ManyToManyField(blank=True, related_name='followers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ class User(AbstractUser):
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -3,10 +3,13 @@ from .models import User, Flavor, Category, Rating, Reply
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
following_count = serializers.IntegerField(source='following.count', read_only=True)
|
||||
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
|
||||
is_following = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'theme', 'avatar']
|
||||
fields = ['id', 'username', 'email', 'theme', 'avatar', 'following_count', 'followers_count', 'is_following']
|
||||
|
||||
def get_avatar(self, obj):
|
||||
if obj.avatar:
|
||||
@@ -16,6 +19,12 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
return obj.avatar.url
|
||||
return None
|
||||
|
||||
def get_is_following(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return request.user.following.filter(pk=obj.pk).exists()
|
||||
return False
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Category
|
||||
@@ -31,6 +40,7 @@ class ReplySerializer(serializers.ModelSerializer):
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
user_id = serializers.IntegerField(source='user.id', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
|
||||
flavor_image = serializers.SerializerMethodField()
|
||||
@@ -40,7 +50,7 @@ class RatingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ['id', 'user', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'score', 'comment', 'created_at', 'replies']
|
||||
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'score', 'comment', 'created_at', 'replies']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
|
||||
+29
-7
@@ -62,13 +62,21 @@ class RatingViewSet(viewsets.ModelViewSet):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
|
||||
# Allow users to see only their own ratings when editing/deleting?
|
||||
# No, for detail views they need to find it.
|
||||
# The permission class IsOwnerOrReadOnly (custom) would be better,
|
||||
# but standard ViewSet logic handles "get_object" which we can protect.
|
||||
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
|
||||
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def feed(self, request):
|
||||
# Get ratings from people the user follows
|
||||
followed_users = request.user.following.all()
|
||||
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
page = self.paginate_queryset(feed_ratings)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(feed_ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
@@ -254,6 +262,20 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
data['message'] = message
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def follow(self, request, pk=None):
|
||||
user_to_follow = self.get_object()
|
||||
if user_to_follow == request.user:
|
||||
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
request.user.following.add(user_to_follow)
|
||||
return Response({'status': 'followed'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def unfollow(self, request, pk=None):
|
||||
user_to_unfollow = self.get_object()
|
||||
request.user.following.remove(user_to_unfollow)
|
||||
return Response({'status': 'unfollowed'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_email(self, request):
|
||||
user = request.user
|
||||
|
||||
@@ -17,7 +17,6 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=narl"
|
||||
# Ultra-clean rule for maximum Traefik compatibility
|
||||
- "traefik.http.routers.holy-energy-backend.priority=100"
|
||||
- "traefik.http.routers.holy-energy-backend.rule=Host(`holy.narl.io`) && (PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/media`))"
|
||||
- "traefik.http.routers.holy-energy-backend.entrypoints=websecure"
|
||||
|
||||
@@ -38,6 +38,7 @@ const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors'))
|
||||
const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const PublicProfile = lazy(() => import('./pages/PublicProfile'));
|
||||
const CommunityFeed = lazy(() => import('./pages/CommunityFeed'));
|
||||
const Login = lazy(() => import('./pages/Login'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
|
||||
@@ -221,6 +222,12 @@ const App: React.FC = () => {
|
||||
<ListItemText primary="Home" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to="/community" onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemText primary="Community" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItemButton onClick={() => setCatOpen(!catOpen)}>
|
||||
<ListItemText primary="Categories" />
|
||||
@@ -294,6 +301,14 @@ const App: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: { xs: 'none', md: 'flex' }, ml: 4 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
component={Link}
|
||||
to="/community"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
|
||||
>
|
||||
Community
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
endIcon={<ArrowDropDownIcon />}
|
||||
@@ -383,6 +398,7 @@ const App: React.FC = () => {
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
<Route path="/flavor/:id" element={<FlavorDetail />} />
|
||||
<Route path="/profile/:username" element={<PublicProfile />} />
|
||||
<Route path="/community" element={<CommunityFeed />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
@@ -283,12 +283,12 @@ const CategoryList: React.FC = () => {
|
||||
<Typography variant="body2" sx={{ fontStyle: 'italic', mb: 1, fontSize: { xs: '0.9rem', md: '1.1rem' }, display: '-webkit-box', overflow: 'hidden', WebkitBoxOrient: 'vertical', WebkitLineClamp: 3 }}>
|
||||
"{featuredReview.comment}"
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar src={featuredReview.user_avatar || undefined} sx={{ width: 20, height: 24, fontSize: '0.6rem' }}>
|
||||
<Link to={`/profile/${featuredReview.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar src={featuredReview.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.6rem', '&:hover': { opacity: 0.8 } }}>
|
||||
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography variant="caption" color="text.secondary">— {featuredReview.user}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ '&:hover': { color: 'primary.main' } }}>— {featuredReview.user}</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -360,11 +360,15 @@ const CategoryList: React.FC = () => {
|
||||
sx={{ p: 3, mb: 3, breakInside: 'avoid', borderRadius: 3, transition: 'border-color 0.2s', '&:hover': { borderColor: 'primary.main' } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Avatar src={review.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1 }}>
|
||||
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Link to={`/profile/${review.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar src={review.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1, '&:hover': { opacity: 0.8 } }}>
|
||||
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{review.user}</Typography>
|
||||
<Link to={`/profile/${review.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>{review.user}</Typography>
|
||||
</Link>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
on <Link to={`/flavor/${review.flavor}`} style={{ color: 'inherit' }}>{review.flavor_name}</Link>
|
||||
</Typography>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Avatar,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Rating as MuiRating,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
interface FeedRating {
|
||||
id: number;
|
||||
user: string;
|
||||
user_id: number;
|
||||
user_avatar: string | null;
|
||||
flavor: number;
|
||||
flavor_name: string;
|
||||
flavor_image: string | null;
|
||||
category_name: string;
|
||||
category_slug: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const CommunityFeed: React.FC = () => {
|
||||
useTitle('Community Feed');
|
||||
const [ratings, setRatings] = useState<FeedRating[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeed = async () => {
|
||||
try {
|
||||
const res = await api.get('ratings/feed/');
|
||||
// Handle pagination if results is present
|
||||
const data = Array.isArray(res.data) ? res.data : (res.data.results || []);
|
||||
setRatings(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch feed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFeed();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Community Feed</Typography>
|
||||
|
||||
{ratings.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>Your feed is quiet...</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Follow other Holy Energy fans to see their latest flavor ratings and reviews here!
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
||||
Find flavors and reviewers
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{ratings.map(rating => (
|
||||
<Card key={rating.id} sx={{ borderRadius: 3, transition: 'transform 0.2s', '&:hover': { transform: 'translateY(-4px)' } }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2 }}>
|
||||
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>
|
||||
{rating.user}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rating.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={{ width: { xs: '100%', sm: 120 }, aspectRatio: '1/1', borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 0.5, '&:hover': { color: 'primary.main' } }}>
|
||||
{rating.flavor_name}
|
||||
</Typography>
|
||||
</Link>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
{rating.category_name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
|
||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
||||
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
|
||||
{rating.comment && (
|
||||
<Typography variant="body2" sx={{
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary',
|
||||
borderLeft: '3px solid',
|
||||
borderColor: 'divider',
|
||||
pl: 2,
|
||||
py: 0.5
|
||||
}}>
|
||||
"{rating.comment}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Internal helper for empty state
|
||||
const Paper = ({ children, sx }: any) => (
|
||||
<Box sx={{ bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', ...sx }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default CommunityFeed;
|
||||
@@ -32,9 +32,13 @@ interface Rating {
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
id: number;
|
||||
username: string;
|
||||
theme: string;
|
||||
avatar: string | null;
|
||||
following_count: number;
|
||||
followers_count: number;
|
||||
is_following: boolean;
|
||||
ratings: Rating[];
|
||||
}
|
||||
|
||||
@@ -43,12 +47,17 @@ const PublicProfile: React.FC = () => {
|
||||
const [data, setData] = useState<ProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await api.get(`users/profile/${username}/`);
|
||||
setData(res.data);
|
||||
const [profileRes, meRes] = await Promise.all([
|
||||
api.get(`users/profile/${username}/`),
|
||||
localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null })
|
||||
]);
|
||||
setData(profileRes.data);
|
||||
if (meRes.data) setCurrentUser(meRes.data.username);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
@@ -58,6 +67,21 @@ const PublicProfile: React.FC = () => {
|
||||
fetchProfile();
|
||||
}, [username]);
|
||||
|
||||
const handleFollowToggle = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
if (data.is_following) {
|
||||
await api.post(`users/${data.id}/unfollow/`);
|
||||
setData({ ...data, is_following: false, followers_count: data.followers_count - 1 });
|
||||
} else {
|
||||
await api.post(`users/${data.id}/follow/`);
|
||||
setData({ ...data, is_following: true, followers_count: data.followers_count + 1 });
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to update follow status. Please login.');
|
||||
}
|
||||
};
|
||||
|
||||
useTitle(data ? `${data.username}'s Ratings` : 'Profile');
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
@@ -104,14 +128,25 @@ const PublicProfile: React.FC = () => {
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3, flexWrap: 'wrap' }}>
|
||||
<Avatar src={data.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main', fontSize: '2.5rem' }}>
|
||||
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
|
||||
{currentUser !== data.username && (
|
||||
<Button
|
||||
variant={data.is_following ? "outlined" : "contained"}
|
||||
onClick={handleFollowToggle}
|
||||
sx={{ borderRadius: 4, px: 4 }}
|
||||
>
|
||||
{data.is_following ? "Following" : "Follow"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Taste Profile • {data.ratings.length} Flavors Rated
|
||||
Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -280,12 +280,16 @@ const FlavorDetail: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2, mt: 0.5 }}>
|
||||
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2, mt: 0.5, '&:hover': { opacity: 0.8 } }}>
|
||||
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{rating.user}</Typography>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>{rating.user}</Typography>
|
||||
</Link>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MuiRating value={rating.score} readOnly size="small" max={10} />
|
||||
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
|
||||
Reference in new issue
Block a user