mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixed ts error
This commit is contained in:
1 parent
02f2866deb
commit
0d7344f5e9
4 files changed
+225
-42
No files matched your search
@@ -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<MentionTextFieldProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
fullWidth = true,
|
||||||
|
multiline = false,
|
||||||
|
rows,
|
||||||
|
size = 'small',
|
||||||
|
onKeyDown
|
||||||
|
}) => {
|
||||||
|
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
// Fetch users for suggestions (we could cache this or search on demand)
|
||||||
|
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
||||||
|
<TextField
|
||||||
|
inputRef={inputRef}
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
multiline={multiline}
|
||||||
|
rows={rows}
|
||||||
|
size={size}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Popper
|
||||||
|
open={showSuggestions}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
placement="bottom-start"
|
||||||
|
style={{ zIndex: 2000, width: 250 }}
|
||||||
|
>
|
||||||
|
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<MenuList dense sx={{ py: 0 }}>
|
||||||
|
{suggestions.map((user, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={user.id}
|
||||||
|
selected={index === selectedIndex}
|
||||||
|
onClick={() => 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) }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||||
|
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={user.username}
|
||||||
|
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Paper>
|
||||||
|
</Popper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MentionTextField;
|
||||||
@@ -33,6 +33,7 @@ import { useTitle } from '../hooks/useTitle';
|
|||||||
import RichText from '../components/RichText';
|
import RichText from '../components/RichText';
|
||||||
import { formatDate } from '../utils/date';
|
import { formatDate } from '../utils/date';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import MentionTextField from '../components/MentionTextField';
|
||||||
|
|
||||||
interface Reply {
|
interface Reply {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -297,11 +298,15 @@ const CommunityFeed: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||||
<TextField
|
<MentionTextField
|
||||||
fullWidth size="small" placeholder={t('community.writeReply')}
|
placeholder={t('community.writeReply')}
|
||||||
value={replyInputs[rating.id] || ''}
|
value={replyInputs[rating.id] || ''}
|
||||||
onChange={(e) => setReplyInputs(prev => ({ ...prev, [rating.id]: e.target.value }))}
|
onChange={(val) => setReplyInputs(prev => ({ ...prev, [rating.id]: val }))}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleReplySubmit(rating.id)}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
handleReplySubmit(rating.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton color="primary" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}>
|
<IconButton color="primary" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Rating as MuiRating,
|
Rating as MuiRating,
|
||||||
Button,
|
Button,
|
||||||
TextField,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Paper,
|
Paper,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
@@ -26,6 +25,7 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
|||||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
import { formatDate } from '../../utils/date';
|
import { formatDate } from '../../utils/date';
|
||||||
|
import MentionTextField from '../../components/MentionTextField';
|
||||||
|
|
||||||
interface Reply {
|
interface Reply {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -351,16 +351,14 @@ const FlavorDetail: React.FC = () => {
|
|||||||
<Typography component="legend" gutterBottom color="text.secondary">Score</Typography>
|
<Typography component="legend" gutterBottom color="text.secondary">Score</Typography>
|
||||||
<MuiRating max={10} value={newScore} onChange={(_, val) => setNewScore(val)} size="large" />
|
<MuiRating max={10} value={newScore} onChange={(_, val) => setNewScore(val)} size="large" />
|
||||||
</Box>
|
</Box>
|
||||||
<TextField
|
<MentionTextField
|
||||||
fullWidth
|
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Share your thoughts..."
|
placeholder="Share your thoughts..."
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(val) => setNewComment(val)}
|
||||||
sx={{ mb: 3, bgcolor: 'background.paper' }}
|
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" type="submit" disabled={!newScore} sx={{ borderRadius: 2 }}>
|
<Button variant="contained" type="submit" disabled={!newScore} sx={{ mt: 3, borderRadius: 2 }}>
|
||||||
Submit Review
|
Submit Review
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -380,12 +378,11 @@ const FlavorDetail: React.FC = () => {
|
|||||||
// Edit Mode
|
// Edit Mode
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<MuiRating max={10} value={editScore} onChange={(_, val) => setEditScore(val || 0)} size="large" />
|
<MuiRating max={10} value={editScore} onChange={(_, val) => setEditScore(val || 0)} size="large" />
|
||||||
<TextField
|
<MentionTextField
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
|
||||||
rows={3}
|
rows={3}
|
||||||
value={editComment}
|
value={editComment}
|
||||||
onChange={(e) => setEditComment(e.target.value)}
|
onChange={(val) => setEditComment(val)}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<Button variant="contained" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
|
<Button variant="contained" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
|
||||||
@@ -479,12 +476,10 @@ const FlavorDetail: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
{editingReplyId === reply.id ? (
|
{editingReplyId === reply.id ? (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||||
<TextField
|
<MentionTextField
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
multiline
|
multiline
|
||||||
value={editReplyText}
|
value={editReplyText}
|
||||||
onChange={(e) => setEditReplyText(e.target.value)}
|
onChange={(val) => setEditReplyText(val)}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button>
|
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button>
|
||||||
@@ -503,15 +498,17 @@ const FlavorDetail: React.FC = () => {
|
|||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||||
<TextField
|
<MentionTextField
|
||||||
size="small"
|
|
||||||
placeholder={t('community.writeReply')}
|
placeholder={t('community.writeReply')}
|
||||||
fullWidth
|
|
||||||
value={replyInputs[rating.id] || ''}
|
value={replyInputs[rating.id] || ''}
|
||||||
onChange={(e) => setReplyInputs({ ...replyInputs, [rating.id]: e.target.value })}
|
onChange={(val) => setReplyInputs({ ...replyInputs, [rating.id]: val })}
|
||||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'background.paper' } }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
handleReplySubmit(rating.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" size="small" disabled={!replyInputs[rating.id]} onClick={() => handleReplySubmit(rating.id)}>{t('common.reply')}</Button>
|
<Button variant="contained" size="small" disabled={!replyInputs[rating.id]} onClick={() => handleReplySubmit(rating.id)} sx={{ height: 40, alignSelf: 'flex-start' }}>{t('common.reply')}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+1
-19
@@ -14,25 +14,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
allowedHosts: allowedHosts,
|
allowedHosts: allowedHosts,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
chunkSizeWarningLimit: 2000,
|
||||||
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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user