diff --git a/frontend/src/components/MentionTextField.tsx b/frontend/src/components/MentionTextField.tsx new file mode 100644 index 0000000..cd23a87 --- /dev/null +++ b/frontend/src/components/MentionTextField.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + TextField, + Popper, + Paper, + MenuList, + MenuItem, + Avatar, + ListItemAvatar, + ListItemText, + Box, + alpha +} from '@mui/material'; +import api from '../api'; + +interface UserSuggestion { + id: number; + username: string; + avatar: string | null; +} + +interface MentionTextFieldProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + fullWidth?: boolean; + multiline?: boolean; + rows?: number; + size?: 'small' | 'medium'; + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +const MentionTextField: React.FC = ({ + value, + onChange, + placeholder, + fullWidth = true, + multiline = false, + rows, + size = 'small', + onKeyDown +}) => { + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + + // Fetch users for suggestions (we could cache this or search on demand) + const [allUsers, setAllUsers] = useState([]); + + useEffect(() => { + const fetchUsers = async () => { + try { + const res = await api.get('flavors/search/'); + const users = (res.data || []) + .filter((item: any) => item.type === 'user') + .map((item: any) => ({ + id: item.id, + username: item.name, + avatar: item.image_url + })); + setAllUsers(users); + } catch (err) { + console.error('Failed to fetch users for mentions'); + } + }; + fetchUsers(); + }, []); + + const handleTextChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + + const selectionStart = e.target.selectionStart || 0; + const textBeforeCursor = newValue.substring(0, selectionStart); + const lastAtPos = textBeforeCursor.lastIndexOf('@'); + + if (lastAtPos !== -1) { + const query = textBeforeCursor.substring(lastAtPos + 1); + // Ensure there's no space or newline between @ and the cursor + if (!query.includes(' ') && !query.includes('\n')) { + const filtered = allUsers.filter(u => + u.username.toLowerCase().startsWith(query.toLowerCase()) + ); + setSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + setSelectedIndex(0); + setAnchorEl(inputRef.current); + return; + } + } + setShowSuggestions(false); + }; + + const handleSelectSuggestion = (user: UserSuggestion) => { + const selectionStart = inputRef.current?.selectionStart || 0; + const textBeforeCursor = value.substring(0, selectionStart); + const textAfterCursor = value.substring(selectionStart); + const lastAtPos = textBeforeCursor.lastIndexOf('@'); + + const newValue = + value.substring(0, lastAtPos) + + '@' + user.username + ' ' + + textAfterCursor; + + onChange(newValue); + setShowSuggestions(false); + + // Return focus to input and move cursor after the mention + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space + inputRef.current.setSelectionRange(newPos, newPos); + } + }, 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (showSuggestions) { + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + handleSelectSuggestion(suggestions[selectedIndex]); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % suggestions.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); + return; + } + if (e.key === 'Escape') { + setShowSuggestions(false); + return; + } + } + if (onKeyDown) onKeyDown(e); + }; + + return ( + + setTimeout(() => setShowSuggestions(false), 200)} + autoComplete="off" + /> + + + + {suggestions.map((user, index) => ( + handleSelectSuggestion(user)} + sx={{ + py: 1, + '&.Mui-selected': { + bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15), + '&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) } + } + }} + > + + + {user.username.charAt(0).toUpperCase()} + + + + + ))} + + + + + ); +}; + +export default MentionTextField; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 7f5f5e6..b31fa93 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -33,6 +33,7 @@ import { useTitle } from '../hooks/useTitle'; import RichText from '../components/RichText'; import { formatDate } from '../utils/date'; import { useTranslation } from 'react-i18next'; +import MentionTextField from '../components/MentionTextField'; interface Reply { id: number; @@ -297,11 +298,15 @@ const CommunityFeed: React.FC = () => { ))} - setReplyInputs(prev => ({ ...prev, [rating.id]: e.target.value }))} - onKeyDown={(e) => e.key === 'Enter' && handleReplySubmit(rating.id)} + onChange={(val) => setReplyInputs(prev => ({ ...prev, [rating.id]: val }))} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleReplySubmit(rating.id); + } + }} /> handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}> diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index de7f9c2..2fee5b9 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -8,7 +8,6 @@ import { CardContent, Rating as MuiRating, Button, - TextField, Avatar, Paper, CircularProgress, @@ -26,6 +25,7 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import VerifiedIcon from '@mui/icons-material/Verified'; import StarIcon from '@mui/icons-material/Star'; import { formatDate } from '../../utils/date'; +import MentionTextField from '../../components/MentionTextField'; interface Reply { id: number; @@ -351,16 +351,14 @@ const FlavorDetail: React.FC = () => { Score setNewScore(val)} size="large" /> - setNewComment(e.target.value)} - sx={{ mb: 3, bgcolor: 'background.paper' }} + onChange={(val) => setNewComment(val)} /> - @@ -380,12 +378,11 @@ const FlavorDetail: React.FC = () => { // Edit Mode setEditScore(val || 0)} size="large" /> - setEditComment(e.target.value)} + onChange={(val) => setEditComment(val)} /> @@ -479,12 +476,10 @@ const FlavorDetail: React.FC = () => { {editingReplyId === reply.id ? ( - setEditReplyText(e.target.value)} + onChange={(val) => setEditReplyText(val)} /> @@ -503,15 +498,17 @@ const FlavorDetail: React.FC = () => { {currentUser && ( - setReplyInputs({ ...replyInputs, [rating.id]: e.target.value })} - sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'background.paper' } }} + onChange={(val) => setReplyInputs({ ...replyInputs, [rating.id]: val })} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleReplySubmit(rating.id); + } + }} /> - + )} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d2aec27..da86efe 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,25 +14,7 @@ export default defineConfig(({ mode }) => { allowedHosts: allowedHosts, }, build: { - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes('node_modules')) { - if (id.includes('@mui')) { - return 'vendor-mui'; - } - if (id.includes('react')) { - return 'vendor-react'; - } - if (id.includes('axios') || id.includes('i18next')) { - return 'vendor-utils'; - } - return 'vendor'; - } - }, - }, - }, - chunkSizeWarningLimit: 1000, // Increase limit slightly as well + chunkSizeWarningLimit: 2000, }, } })