Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-17 18:18:58 +01:00
commit 924c769764
7 files changed
+394 -16

No files matched your search

@@ -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'],
},
),
]
+19
View File
@@ -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}"
+38 -2
View File
@@ -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
+2
View File
@@ -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)),
+78 -4
View File
@@ -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()
+127 -2
View File
@@ -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<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(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<Notification[]>([]);
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 ? (
<>
<ListItem disablePadding>
<ListItemButton onClick={(e) => setNotifAnchorEl(e.currentTarget)}>
<ListItemText
primary="Notifications"
secondary={user.unread_notifications_count > 0 ? `${user.unread_notifications_count} unread` : null}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to={`/profile/${user.username}`} onClick={() => setDrawerOpen(false)}>
<ListItemText
@@ -413,7 +482,63 @@ const App: React.FC = () => {
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{!loadingUser && (
user ? (
<Box sx={{ display: { xs: 'none', md: 'flex' } }}>
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center' }}>
<IconButton color="inherit" onClick={(e) => setNotifAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
<Badge badgeContent={user.unread_notifications_count} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
<Menu
anchorEl={notifAnchorEl}
open={Boolean(notifAnchorEl)}
onClose={() => setNotifAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
PaperProps={{ sx: { width: 320, maxHeight: 400 } }}
>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>Notifications</Typography>
{user.unread_notifications_count > 0 && (
<Button size="small" onClick={handleMarkAllRead}>Mark all read</Button>
)}
</Box>
{notifications.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">No notifications yet</Typography>
</Box>
) : (
notifications.map(n => (
<MenuItem
key={n.id}
onClick={() => 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'
}}
>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
{n.actor_username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
<strong>{n.actor_username}</strong> {n.notification_type === 'reply' ? 'replied to your review' : 'mentioned you'} on <strong>{n.flavor_name}</strong>
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(n.created_at).toLocaleDateString()}
</Typography>
</Box>
</MenuItem>
))
)}
</Menu>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 36, height: 36, border: '2px solid', borderColor: 'primary.main' }}>
{user.username ? user.username.charAt(0).toUpperCase() : '?'}
+94 -8
View File
@@ -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<Flavor | null>(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<number | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
const renderTextWithMentions = (text: string) => {
const parts = text.split(/(@\w+)/g);
return parts.map((part, i) => {
if (part.startsWith('@')) {
const username = part.substring(1);
return (
<Link
key={i}
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: 'none',
fontWeight: 'bold',
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px',
borderRadius: '4px'
}}
>
{part}
</Link>
);
}
return part;
});
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (!flavor) return <Typography>Flavor not found</Typography>;
return (
@@ -374,7 +427,7 @@ const FlavorDetail: React.FC = () => {
{rating.comment && (
<Typography variant="body1" sx={{ mb: 3, lineHeight: 1.6 }}>
{rating.comment}
{renderTextWithMentions(rating.comment)}
</Typography>
)}
@@ -404,11 +457,44 @@ const FlavorDetail: React.FC = () => {
}}>
{rating.replies.map((reply: any) => (
<Box key={reply.id} sx={{ mb: 1.5, '&:last-child': { mb: 0 } }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}>
{reply.user}
{reply.user === rating.user && <VerifiedIcon sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5, verticalAlign: 'middle' }} />}
</Typography>
<Typography variant="body2">{reply.text}</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Link to={`/profile/${reply.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}>
{reply.user}
{reply.user === rating.user && <VerifiedIcon sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5, verticalAlign: 'middle' }} />}
</Typography>
</Link>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
{new Date(reply.created_at).toLocaleDateString()} {new Date(reply.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
{currentUser === reply.user && editingReplyId !== reply.id && (
<Box sx={{ display: 'flex' }}>
<Button size="small" sx={{ minWidth: 0, p: 0.5, fontSize: '0.7rem' }} onClick={() => { setEditingReplyId(reply.id); setEditReplyText(reply.text); }}>Edit</Button>
<Button size="small" color="error" sx={{ minWidth: 0, p: 0.5, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
</Box>
)}
</Box>
</Box>
{editingReplyId === reply.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
<TextField
size="small"
fullWidth
multiline
value={editReplyText}
onChange={(e) => setEditReplyText(e.target.value)}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>Save</Button>
<Button size="small" onClick={() => setEditingReplyId(null)}>Cancel</Button>
</Box>
</Box>
) : (
<Typography variant="body2" sx={{ lineHeight: 1.5 }}>
{renderTextWithMentions(reply.text)}
</Typography>
)}
</Box>
))}
</Box>