added avatars

This commit is contained in:
nvrl committed 2026-02-16 15:09:40 +01:00
1 parent 481bff7603
commit 606c3bc2a7
9 files changed
+134 -14

No files matched your search

+16
View File
@@ -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
+21 -2
View File
@@ -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:
+16
View File
@@ -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
+17 -4
View File
@@ -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<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(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 = () => {
</Box>
)}
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
<PaletteIcon />
</IconButton>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
{user?.avatar ? (
<Avatar src={user.avatar} sx={{ width: 32, height: 32 }} />
) : (
<PaletteIcon />
)}
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
+4 -1
View File
@@ -37,6 +37,7 @@ interface Flavor {
interface Review {
id: number;
user: string;
user_avatar: string | null;
flavor_name: string;
flavor_image: string | null;
score: number;
@@ -268,7 +269,9 @@ const CategoryList: React.FC = () => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar sx={{ width: 32, height: 32, mr: 1 }}>{review.user.charAt(0).toUpperCase()}</Avatar>
<Avatar src={review.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1 }}>
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{review.user}</Typography>
<Typography variant="caption" color="text.secondary">
+8 -3
View File
@@ -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 (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h4">Personal Dashboard</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 48, height: 48 }}>
{!data.user.avatar && data.user.username.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="h4">Personal Dashboard</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
+3 -2
View File
@@ -26,6 +26,7 @@ interface Rating {
interface ProfileData {
username: string;
theme: string;
avatar: string | null;
ratings: Rating[];
}
@@ -59,8 +60,8 @@ const PublicProfile: React.FC = () => {
return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3 }}>
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main' }}>
{data.username.charAt(0).toUpperCase()}
<Avatar src={data.avatar || undefined} sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main' }}>
{!data.avatar && data.username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{data.username}</Typography>
+45 -1
View File
@@ -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<string | null>(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<HTMLInputElement>) => {
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 = () => {
</Alert>
)}
<Paper sx={{ p: 3, mb: 4 }}>
<Typography variant="h6" gutterBottom>Profile Picture</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 2 }}>
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Button variant="outlined" component="label">
Upload New Avatar
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
</Button>
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
Max size: 2MB. JPG, PNG or WEBP.
</Typography>
</Box>
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 4 }}>
<Typography variant="h6" gutterBottom>Profile Information</Typography>
<form onSubmit={handleUpdateProfile}>
+4 -1
View File
@@ -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 = () => {
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar sx={{ width: 32, height: 32, mr: 1.5 }}>{rating.user.charAt(0).toUpperCase()}</Avatar>
<Avatar src={rating.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1.5 }}>
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{rating.user}</Typography>
<Box sx={{ flexGrow: 1 }} />
<MuiRating value={rating.score} readOnly size="small" max={10} />