diff --git a/backend/api/models.py b/backend/api/models.py index 2a9a096..06f63e1 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -20,9 +20,25 @@ class User(AbstractUser): ('forest', 'Forest Dark'), ] theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha') + 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) + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.avatar: + try: + from PIL import Image + img = Image.open(self.avatar.path) + if img.height > 256 or img.width > 256 or img.format != 'JPEG': + output_size = (256, 256) + img.thumbnail(output_size) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save(self.avatar.path, 'JPEG', quality=90) + except Exception: + pass + def __str__(self): return self.username diff --git a/backend/api/serializers.py b/backend/api/serializers.py index ea8688d..23ca690 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -2,9 +2,19 @@ from rest_framework import serializers from .models import User, Flavor, Category, Rating, Reply class UserSerializer(serializers.ModelSerializer): + avatar = serializers.SerializerMethodField() + class Meta: model = User - fields = ['id', 'username', 'email', 'theme'] + fields = ['id', 'username', 'email', 'theme', 'avatar'] + + def get_avatar(self, obj): + if obj.avatar: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.avatar.url) + return obj.avatar.url + return None class CategorySerializer(serializers.ModelSerializer): class Meta: @@ -21,13 +31,22 @@ class ReplySerializer(serializers.ModelSerializer): class RatingSerializer(serializers.ModelSerializer): user = serializers.StringRelatedField(read_only=True) + user_avatar = serializers.SerializerMethodField() flavor_name = serializers.CharField(source='flavor.name', read_only=True) flavor_image = serializers.SerializerMethodField() replies = ReplySerializer(many=True, read_only=True) class Meta: model = Rating - fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at', 'replies'] + fields = ['id', 'user', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at', 'replies'] + + def get_user_avatar(self, obj): + if obj.user.avatar: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.user.avatar.url) + return obj.user.avatar.url + return None def get_flavor_image(self, obj): if obj.flavor.image: diff --git a/backend/api/views.py b/backend/api/views.py index 0056372..23e1809 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -171,6 +171,22 @@ class UserViewSet(viewsets.ModelViewSet): return Response({'status': 'theme updated', 'theme': theme}) return Response({'error': 'Invalid theme'}, status=status.HTTP_400_BAD_REQUEST) + @action(detail=False, methods=['post']) + def update_avatar(self, request): + user = request.user + avatar = request.FILES.get('avatar') + if not avatar: + return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST) + + # Check size (2MB = 2 * 1024 * 1024 bytes) + if avatar.size > 2 * 1024 * 1024: + return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST) + + user.avatar = avatar + user.save() + serializer = self.get_serializer(user) + return Response(serializer.data) + @action(detail=False, methods=['post']) def change_password(self, request): user = request.user diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b725f9e..2e9dada 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { Button, Box, IconButton, + Avatar, Menu, MenuItem, Drawer, @@ -138,7 +139,7 @@ const GlobalSearch = () => { const App: React.FC = () => { const [themeName, setThemeName] = useState((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha'); const [anchorEl, setAnchorEl] = useState(null); - const [user, setUser] = useState<{username: string} | null>(null); + const [user, setUser] = useState<{username: string, avatar: string | null} | null>(null); const [loadingUser, setLoadingUser] = useState(true); const [drawerOpen, setDrawerOpen] = useState(false); const isMobile = useMediaQuery('(max-width:900px)'); @@ -259,9 +260,21 @@ const App: React.FC = () => { )} - setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> - - + setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> + + {user?.avatar ? ( + + + + ) : ( + + + + )} + + + + { }} > - {review.user.charAt(0).toUpperCase()} + + {!review.user_avatar && review.user.charAt(0).toUpperCase()} + {review.user} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d209d71..1c96d2e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Typography, Card, CardContent, Box, Rating as MuiRating, Button, TextField, Container, CircularProgress } from '@mui/material'; +import { Typography, Card, CardContent, Box, Rating as MuiRating, Button, TextField, Container, CircularProgress, Avatar } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; interface DashboardData { - user: { username: string }; + user: { username: string, avatar: string | null }; rated_count: number; missing_count: number; missing_flavors: any[]; @@ -44,7 +44,12 @@ const Dashboard: React.FC = () => { return ( - Personal Dashboard + + + {!data.user.avatar && data.user.username.charAt(0).toUpperCase()} + + Personal Dashboard + { return ( - - {data.username.charAt(0).toUpperCase()} + + {!data.avatar && data.username.charAt(0).toUpperCase()} {data.username} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9edb441..fae5553 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,7 +6,8 @@ import { Button, Paper, Alert, - Container + Container, + Avatar } from '@mui/material'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; @@ -16,6 +17,7 @@ const Settings: React.FC = () => { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState(''); + const [avatar, setAvatar] = useState(null); const [confirmationCode, setConfirmationCode] = useState(''); const [pendingConfirmation, setPendingConfirmation] = useState(false); const [oldPassword, setOldPassword] = useState(''); @@ -29,6 +31,7 @@ const Settings: React.FC = () => { setUsername(res.data.username); setEmail(res.data.email); setCurrentEmail(res.data.email); + setAvatar(res.data.avatar); } catch (err) { console.error(err); } @@ -36,6 +39,29 @@ const Settings: React.FC = () => { fetchUser(); }, []); + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 2 * 1024 * 1024) { + setMessage({ type: 'error', text: 'File size exceeds 2MB limit' }); + return; + } + + const formData = new FormData(); + formData.append('avatar', file); + + try { + const res = await api.post('users/update_avatar/', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + setAvatar(res.data.avatar); + setMessage({ type: 'success', text: 'Avatar updated successfully!' }); + } catch (err: any) { + setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' }); + } + }; + const handleUpdateProfile = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -87,6 +113,24 @@ const Settings: React.FC = () => { )} + + Profile Picture + + + {!avatar && username.charAt(0).toUpperCase()} + + + + + Max size: 2MB. JPG, PNG or WEBP. + + + + + Profile Information
diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index b1dae09..65ac7ba 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -27,6 +27,7 @@ interface Reply { interface Rating { id: number; user: string; + user_avatar: string | null; score: number; comment: string; created_at: string; @@ -224,7 +225,9 @@ const FlavorDetail: React.FC = () => { ) : ( <> - {rating.user.charAt(0).toUpperCase()} + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + {rating.user}