diff --git a/backend/api/migrations/0014_alter_user_theme_notification.py b/backend/api/migrations/0014_alter_user_theme_notification.py new file mode 100644 index 0000000..af6a2c1 --- /dev/null +++ b/backend/api/migrations/0014_alter_user_theme_notification.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.2 on 2026-02-17 17:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_user_language'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='theme', + field=models.CharField(choices=[('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Latte'), ('pink', 'Pastel Pink'), ('solarized_light', 'Solarized Light'), ('one_light', 'One Light'), ('paper', 'Paper White'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='holy_light', max_length=20), + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_type', models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention')], max_length=10)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), + ('rating', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.rating')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.reply')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 064f1ff..7cbe0a8 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -119,3 +119,22 @@ class Reply(models.Model): def __str__(self): return f"Reply by {self.user.username} on {self.rating.id}" + +class Notification(models.Model): + TYPE_CHOICES = [ + ('reply', 'Reply'), + ('mention', 'Mention'), + ] + recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE) + actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) + notification_type = models.CharField(max_length=10, choices=TYPE_CHOICES) + rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True) + reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True) + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.notification_type} for {self.recipient.username}" diff --git a/backend/api/serializers.py b/backend/api/serializers.py index e1b3e1b..b4b3af3 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,15 +1,16 @@ from rest_framework import serializers -from .models import User, Flavor, Category, Rating, Reply +from .models import User, Flavor, Category, Rating, Reply, Notification 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() + unread_notifications_count = serializers.SerializerMethodField() class Meta: model = User - fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following'] + fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count'] def get_avatar(self, obj): if obj.avatar: @@ -25,6 +26,41 @@ class UserSerializer(serializers.ModelSerializer): return request.user.following.filter(pk=obj.pk).exists() return False + def get_unread_notifications_count(self, obj): + return obj.notifications.filter(is_read=False).count() + +class NotificationSerializer(serializers.ModelSerializer): + actor_username = serializers.CharField(source='actor.username', read_only=True) + actor_avatar = serializers.SerializerMethodField() + flavor_name = serializers.SerializerMethodField() + flavor_id = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'is_read', 'created_at', 'flavor_name', 'flavor_id'] + + def get_actor_avatar(self, obj): + if obj.actor.avatar: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.actor.avatar.url) + return obj.actor.avatar.url + return None + + def get_flavor_name(self, obj): + if obj.rating: + return obj.rating.flavor.name + if obj.reply: + return obj.reply.rating.flavor.name + return None + + def get_flavor_id(self, obj): + if obj.rating: + return obj.rating.flavor.id + if obj.reply: + return obj.reply.rating.flavor.id + return None + class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category diff --git a/backend/api/urls.py b/backend/api/urls.py index c3fedd6..2389979 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -7,6 +7,8 @@ router.register('users', views.UserViewSet) router.register('flavors', views.FlavorViewSet) router.register('categories', views.CategoryViewSet) router.register('ratings', views.RatingViewSet) +router.register('replies', views.ReplyViewSet) +router.register('notifications', views.NotificationViewSet, basename='notification') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views.py b/backend/api/views.py index 8f18b17..6bc2507 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -7,8 +7,25 @@ from django_filters.rest_framework import DjangoFilterBackend from django_ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator from rest_framework.pagination import PageNumberPagination -from .models import User, Flavor, Category, Rating, Reply -from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer +from .models import User, Flavor, Category, Rating, Reply, Notification +from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer + +def parse_mentions(text, actor, rating=None, reply=None): + import re + mentions = re.findall(r'@(\w+)', text) + for username in set(mentions): + try: + recipient = User.objects.get(username__iexact=username) + if recipient != actor: + Notification.objects.get_or_create( + recipient=recipient, + actor=actor, + notification_type='mention', + rating=rating, + reply=reply + ) + except User.DoesNotExist: + continue class FeedPagination(PageNumberPagination): page_size = 10 @@ -98,7 +115,10 @@ class RatingViewSet(viewsets.ModelViewSet): flavor = serializer.validated_data['flavor'] if Rating.objects.filter(user=self.request.user, flavor=flavor).exists(): raise serializers.ValidationError('You have already rated this flavor.') - serializer.save(user=self.request.user) + rating = serializer.save(user=self.request.user) + # Parse mentions in the rating comment + if rating.comment: + parse_mentions(rating.comment, self.request.user, rating=rating) def get_queryset(self): return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at') @@ -137,7 +157,61 @@ class RatingViewSet(viewsets.ModelViewSet): return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST) reply = Reply.objects.create(user=request.user, rating=rating, text=text) - return Response(ReplySerializer(reply).data, status=status.HTTP_201_CREATED) + + # Notify the rating author if it's not the same person + if rating.user != request.user: + Notification.objects.create( + recipient=rating.user, + actor=request.user, + notification_type='reply', + rating=rating, + reply=reply + ) + + # Parse mentions in the reply text + parse_mentions(text, request.user, rating=rating, reply=reply) + + return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED) + +class ReplyViewSet(viewsets.ModelViewSet): + queryset = Reply.objects.all() + serializer_class = ReplySerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Reply.objects.select_related('user', 'rating').order_by('created_at') + + def perform_update(self, serializer): + reply = self.get_object() + if reply.user != self.request.user: + raise permissions.PermissionDenied("You cannot edit this reply.") + new_reply = serializer.save() + # Parse mentions in the updated text + parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply) + + def perform_destroy(self, instance): + if instance.user != self.request.user: + raise permissions.PermissionDenied("You cannot delete this reply.") + instance.delete() + +class NotificationViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = NotificationSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at') + + @action(detail=False, methods=['post']) + def mark_all_read(self, request): + request.user.notifications.filter(is_read=False).update(is_read=True) + return Response({'status': 'all read'}) + + @action(detail=True, methods=['post']) + def mark_read(self, request, pk=None): + notification = self.get_object() + notification.is_read = True + notification.save() + return Response({'status': 'marked read'}) class UserViewSet(viewsets.ModelViewSet): queryset = User.objects.all() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13b26ca..d9de4c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,10 +25,12 @@ import { CircularProgress, ListItemAvatar, ListSubheader, + Badge, alpha } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import SearchIcon from '@mui/icons-material/Search'; +import NotificationsIcon from '@mui/icons-material/Notifications'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; @@ -59,6 +61,19 @@ interface SearchResult { slug: string | null; } +interface Notification { + id: number; + actor_username: string; + actor_avatar: string | null; + notification_type: 'reply' | 'mention'; + rating: number | null; + reply: number | null; + is_read: boolean; + created_at: string; + flavor_name: string | null; + flavor_id: number | null; +} + const GlobalSearch = () => { const { t } = useTranslation(); const [query, setQuery] = useState(''); @@ -169,14 +184,25 @@ const App: React.FC = () => { const { i18n, t } = useTranslation(); const [themeName, setThemeName] = useState((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light'); const [anchorEl, setAnchorEl] = useState(null); + const [notifAnchorEl, setNotifAnchorEl] = useState(null); const [catAnchorEl, setCatAnchorEl] = useState(null); const [categories, setCategories] = useState<{name: string, slug: string}[]>([]); - const [user, setUser] = useState<{username: string, avatar: string | null} | null>(null); + const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number} | null>(null); + const [notifications, setNotifications] = useState([]); const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]); const [loadingUser, setLoadingUser] = useState(true); const [drawerOpen, setDrawerOpen] = useState(false); const [catOpen, setCatOpen] = useState(false); + const fetchNotifications = async () => { + try { + const res = await api.get('notifications/'); + setNotifications(res.data); + } catch (err) { + console.error('Failed to fetch notifications'); + } + }; + useEffect(() => { const fetchData = async () => { try { @@ -195,6 +221,7 @@ const App: React.FC = () => { try { const followRes = await api.get('users/following_list/'); setFollowing(followRes.data); + fetchNotifications(); } catch (err) { console.error('Failed to fetch following list'); } @@ -211,10 +238,44 @@ const App: React.FC = () => { } }; fetchData(); + + // Poll for notifications every 60 seconds + const interval = setInterval(() => { + if (localStorage.getItem('token')) { + fetchNotifications(); + // Also refresh user data for the count + api.get('users/me/').then(res => setUser(res.data)).catch(() => {}); + } + }, 60000); + return () => clearInterval(interval); }, []); const theme = useMemo(() => getTheme(themeName), [themeName]); + const handleMarkAllRead = async () => { + try { + await api.post('notifications/mark_all_read/'); + setNotifications(notifications.map(n => ({ ...n, is_read: true }))); + if (user) setUser({ ...user, unread_notifications_count: 0 }); + } catch (err) { + console.error('Failed to mark all as read'); + } + }; + + const handleNotificationClick = async (notif: Notification) => { + if (!notif.is_read) { + try { + await api.post(`notifications/${notif.id}/mark_read/`); + setNotifications(notifications.map(n => n.id === notif.id ? { ...n, is_read: true } : n)); + if (user) setUser({ ...user, unread_notifications_count: Math.max(0, user.unread_notifications_count - 1) }); + } catch (err) { /* ignore */ } + } + setNotifAnchorEl(null); + if (notif.flavor_id) { + window.location.href = `/flavor/${notif.flavor_id}`; + } + }; + const handleThemeChange = async (newTheme: CatppuccinTheme) => { setThemeName(newTheme); localStorage.setItem('theme', newTheme); @@ -283,6 +344,14 @@ const App: React.FC = () => { {user ? ( <> + + setNotifAnchorEl(e.currentTarget)}> + 0 ? `${user.unread_notifications_count} unread` : null} + /> + + setDrawerOpen(false)}> { {!loadingUser && ( user ? ( - + + setNotifAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> + + + + + setNotifAnchorEl(null)} + elevation={3} + sx={{ mt: 1 }} + PaperProps={{ sx: { width: 320, maxHeight: 400 } }} + > + + Notifications + {user.unread_notifications_count > 0 && ( + + )} + + {notifications.length === 0 ? ( + + No notifications yet + + ) : ( + notifications.map(n => ( + handleNotificationClick(n)} + sx={{ + py: 1.5, + px: 2, + borderBottom: '1px solid', + borderColor: 'divider', + bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05), + whiteSpace: 'normal', + display: 'flex', + gap: 2, + alignItems: 'flex-start' + }} + > + + {n.actor_username.charAt(0).toUpperCase()} + + + + {n.actor_username} {n.notification_type === 'reply' ? 'replied to your review' : 'mentioned you'} on {n.flavor_name} + + + {new Date(n.created_at).toLocaleDateString()} + + + + )) + )} + + setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> {user.username ? user.username.charAt(0).toUpperCase() : '?'} diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index eff4848..27b9f1a 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -16,7 +16,8 @@ import { Grid, Chip, Stack, - alpha + alpha, + useTheme } from '@mui/material'; import api from '../../api'; import { useTitle } from '../../hooks/useTitle'; @@ -59,6 +60,7 @@ interface Flavor { const FlavorDetail: React.FC = () => { const { t } = useTranslation(); + const theme = useTheme(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [flavor, setFlavor] = useState(null); @@ -75,6 +77,10 @@ const FlavorDetail: React.FC = () => { const [editScore, setEditScore] = useState(0); const [editComment, setEditComment] = useState(''); + // Reply Edit state + const [editingReplyId, setEditingReplyId] = useState(null); + const [editReplyText, setEditReplyText] = useState(''); + const fetchFlavor = async () => { try { const res = await api.get(`flavors/${id}/`); @@ -127,6 +133,27 @@ const FlavorDetail: React.FC = () => { } }; + const handleUpdateReply = async (replyId: number) => { + if (!editReplyText) return; + try { + await api.patch(`replies/${replyId}/`, { text: editReplyText }); + setEditingReplyId(null); + fetchFlavor(); + } catch (err) { + alert('Failed to update reply'); + } + }; + + const handleDeleteReply = async (replyId: number) => { + if (!confirm('Delete this reply?')) return; + try { + await api.delete(`replies/${replyId}/`); + fetchFlavor(); + } catch (err) { + alert('Failed to delete reply'); + } + }; + const handleDeleteRating = async (ratingId: number) => { if (!confirm('Are you sure?')) return; try { @@ -161,7 +188,33 @@ const FlavorDetail: React.FC = () => { } }; - if (loading) return ; + const renderTextWithMentions = (text: string) => { + const parts = text.split(/(@\w+)/g); + return parts.map((part, i) => { + if (part.startsWith('@')) { + const username = part.substring(1); + return ( + + {part} + + ); + } + return part; + }); + }; + + if (loading) return ; if (!flavor) return Flavor not found; return ( @@ -374,7 +427,7 @@ const FlavorDetail: React.FC = () => { {rating.comment && ( - {rating.comment} + {renderTextWithMentions(rating.comment)} )} @@ -404,11 +457,44 @@ const FlavorDetail: React.FC = () => { }}> {rating.replies.map((reply: any) => ( - - {reply.user} - {reply.user === rating.user && } - - {reply.text} + + + + {reply.user} + {reply.user === rating.user && } + + + + + {new Date(reply.created_at).toLocaleDateString()} {new Date(reply.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + {currentUser === reply.user && editingReplyId !== reply.id && ( + + + + + )} + + + {editingReplyId === reply.id ? ( + + setEditReplyText(e.target.value)} + /> + + + + + + ) : ( + + {renderTextWithMentions(reply.text)} + + )} ))}