mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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() : '?'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user