mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
added avatars
This commit is contained in:
1 parent
481bff7603
commit
606c3bc2a7
9 files changed
+134
-14
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user