You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react';
|
|
import { fetchReactionsForIssue, getAnonymousUserId, toggleReactionApi } from '../api';
|
|
|
|
type CommentReactions = { [emoji: string]: string[] };
|
|
interface ReactionsProps { issueId: number; commentId: string; }
|
|
|
|
const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
|
|
const [reactions, setReactions] = useState<CommentReactions>({});
|
|
const [myUserId, setMyUserId] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
const pickerContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [userId, allReactions] = await Promise.all([
|
|
getAnonymousUserId(),
|
|
fetchReactionsForIssue(issueId)
|
|
]);
|
|
|
|
setMyUserId(userId);
|
|
if (allReactions && allReactions[commentId]) {
|
|
setReactions(allReactions[commentId]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch data:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (pickerContainerRef.current && !pickerContainerRef.current.contains(event.target as Node)) {
|
|
setShowPicker(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [issueId, commentId]);
|
|
|
|
const handleReactionClick = async (emoji: string) => {
|
|
if (!myUserId) return;
|
|
const hasReacted = (reactions[emoji] || []).includes(myUserId);
|
|
const method = hasReacted ? 'DELETE' : 'POST';
|
|
const previousReactions = { ...reactions };
|
|
|
|
setReactions(prev => {
|
|
const users = prev[emoji] || [];
|
|
const newUsers = hasReacted ? users.filter(id => id !== myUserId) : [...users, myUserId];
|
|
const newReactionsForComment = { ...prev, [emoji]: newUsers };
|
|
if (newReactionsForComment[emoji].length === 0) {
|
|
delete newReactionsForComment[emoji];
|
|
}
|
|
return newReactionsForComment;
|
|
});
|
|
|
|
try {
|
|
const response = await toggleReactionApi(issueId, commentId, emoji, myUserId, method);
|
|
if (!response.ok) throw new Error('Server returned an error');
|
|
} catch (error) {
|
|
console.error("Failed to update reaction:", error);
|
|
setReactions(previousReactions);
|
|
}
|
|
};
|
|
|
|
const onEmojiClick = (emojiData: EmojiClickData) => {
|
|
setShowPicker(false);
|
|
handleReactionClick(emojiData.emoji);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div style={styles.container}>
|
|
<div style={{ ...styles.skeleton, width: '50px' }}></div>
|
|
<div style={{ ...styles.skeleton, width: '45px' }}></div>
|
|
<div style={styles.addButtonSkeleton}></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!myUserId) return null;
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{Object.entries(reactions).map(([emoji, users]) => {
|
|
if (users.length === 0) return null;
|
|
const isSelected = users.includes(myUserId);
|
|
return (
|
|
<div key={emoji} style={{ ...styles.badge, ...(isSelected ? styles.badgeSelected : {}) }} title={users.join(', ')} onClick={() => handleReactionClick(emoji)}>
|
|
<span style={styles.emoji}>{emoji}</span>
|
|
<span style={{ ...styles.count, ...(isSelected ? styles.countSelected : {}) }}>{users.length}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
<div style={{ position: 'relative' }} ref={pickerContainerRef}>
|
|
<button style={styles.addButton} title="Добавить реакцию" onClick={() => setShowPicker(prev => !prev)}>
|
|
<span>+😊</span>
|
|
</button>
|
|
{showPicker && (
|
|
<div style={styles.pickerWrapper}>
|
|
<EmojiPicker onEmojiClick={onEmojiClick} height={350} width={300} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const styles: { [key: string]: React.CSSProperties } = {
|
|
container: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px', marginTop: '8px', marginBottom: '8px', paddingLeft: '10px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', fontSize: '13px', minHeight: '36px' },
|
|
badge: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px solid #dcdcdc', borderRadius: '12px', cursor: 'pointer', transition: 'background-color 0.2s', backgroundColor: '#f7f7f7' },
|
|
badgeSelected: { backgroundColor: '#e6f2ff', borderColor: '#99ccff' },
|
|
emoji: { marginRight: '4px', fontSize: '15px', lineHeight: '1' },
|
|
count: { fontWeight: 600, color: '#333' },
|
|
countSelected: { color: '#005cc5' },
|
|
addButton: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px dashed #ccc', borderRadius: '12px', backgroundColor: 'transparent', cursor: 'pointer', color: '#555', fontFamily: 'inherit', fontSize: '13px' },
|
|
pickerWrapper: { position: 'absolute', bottom: '100%', left: 0, marginBottom: '10px', zIndex: 1000 },
|
|
skeleton: { height: '26px', backgroundColor: '#eef0f2', borderRadius: '12px', animation: 'reactions-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite' },
|
|
addButtonSkeleton: { height: '26px', width: '42px', backgroundColor: 'transparent', border: '1px dashed #e0e0e0', borderRadius: '12px' },
|
|
};
|
|
export default Reactions;
|