/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/CommentPanel.tsx
342 строки
11 KB
Anton Kravchenkov
fix(ui): Resolve comments issue
26 мар 2026, 00:38
26 мар 2026, 00:38
a96d0fd
Код
Авторство
О чём код?
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { resolveAvatarColor } from '../utils/avatarUtils'; import { apiGetComments, apiCreateComment, apiUpdateComment, apiDeleteComment, isUnauthorizedError, handleUnauthorized, type CommentDto, } from '../utils/api'; interface CommentPanelProps { sheetId: string; tabId: string; rowIndex: number; colIndex: number; cellLabel: string; currentUsername: string; canEdit: boolean; anchorRect?: DOMRect; onClose: () => void; onCommentsChanged: () => void; onPanelMouseEnter?: () => void; onPanelMouseLeave?: () => void; } export const CommentPanel: React.FC<CommentPanelProps> = ({ sheetId, tabId, rowIndex, colIndex, cellLabel, currentUsername, canEdit, anchorRect, onClose, onCommentsChanged, onPanelMouseEnter, onPanelMouseLeave, }) => { const [comments, setComments] = useState<CommentDto[]>([]); const [loading, setLoading] = useState(true); const [newText, setNewText] = useState(''); const [replyTo, setReplyTo] = useState<string | null>(null); const [replyText, setReplyText] = useState(''); const [editingId, setEditingId] = useState<string | null>(null); const [editText, setEditText] = useState(''); const [sending, setSending] = useState(false); const panelRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null); const loadComments = useCallback(async () => { try { const data = await apiGetComments(sheetId, tabId, rowIndex, colIndex); setComments(data); } catch (err) { if (isUnauthorizedError(err)) { handleUnauthorized(); return; } console.error(err); } finally { setLoading(false); } }, [sheetId, tabId, rowIndex, colIndex]); useEffect(() => { loadComments(); }, [loadComments]); useEffect(() => { const handler = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { const target = e.target as HTMLElement; const cell = target.closest?.('.grid-cell'); if (cell?.querySelector('.cell-comment-indicator')) return; onClose(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [onClose]); useEffect(() => { const gridScroll = document.querySelector('.grid-body, .grid-canvas-scroll'); if (!gridScroll) return; const handler = () => onClose(); gridScroll.addEventListener('scroll', handler, { passive: true }); return () => gridScroll.removeEventListener('scroll', handler); }, [onClose]); const handleCreate = async () => { if (!newText.trim() || sending) return; setSending(true); try { await apiCreateComment(sheetId, tabId, rowIndex, colIndex, newText.trim()); setNewText(''); await loadComments(); onCommentsChanged(); } catch (err) { if (isUnauthorizedError(err)) { handleUnauthorized(); return; } console.error(err); } finally { setSending(false); } }; const handleReply = async (parentId: string) => { if (!replyText.trim() || sending) return; setSending(true); try { await apiCreateComment(sheetId, tabId, rowIndex, colIndex, replyText.trim(), parentId); setReplyText(''); setReplyTo(null); await loadComments(); onCommentsChanged(); } catch (err) { if (isUnauthorizedError(err)) { handleUnauthorized(); return; } console.error(err); } finally { setSending(false); } }; const handleUpdate = async (commentId: string) => { if (!editText.trim() || sending) return; setSending(true); try { await apiUpdateComment(sheetId, commentId, editText.trim()); setEditingId(null); setEditText(''); await loadComments(); } catch (err) { if (isUnauthorizedError(err)) { handleUnauthorized(); return; } console.error(err); } finally { setSending(false); } }; const handleDelete = async (commentId: string) => { if (sending) return; setSending(true); try { await apiDeleteComment(sheetId, commentId); await loadComments(); onCommentsChanged(); } catch (err) { if (isUnauthorizedError(err)) { handleUnauthorized(); return; } console.error(err); } finally { setSending(false); } }; const startEdit = (comment: CommentDto) => { setEditingId(comment.id); setEditText(comment.text); setReplyTo(null); }; const formatDate = (iso: string) => { const d = new Date(iso); return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }); }; const renderComment = (comment: CommentDto, isReply = false) => { const isAuthor = comment.authorUsername === currentUsername; const isEditing = editingId === comment.id; return ( <div key={comment.id} className={`comment-item${isReply ? ' comment-reply' : ''}`}> <div className="comment-header"> <span className="comment-avatar" style={{ backgroundColor: resolveAvatarColor(comment.authorUsername, comment.authorAvatarColor), color: 'white' }}> {comment.authorUsername.charAt(0).toUpperCase()} </span> <div className="comment-meta"> <span className="comment-author">{comment.authorUsername}</span> <span className="comment-date">{formatDate(comment.createdAt)}</span> </div> {isAuthor && !isEditing && ( <div className="comment-actions"> <button className="comment-action-btn" onClick={() => startEdit(comment)} title="Редактировать"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <path d="M10 1.5L12.5 4L4.5 12H2V9.5L10 1.5Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> </svg> </button> <button className="comment-action-btn comment-action-delete" onClick={() => handleDelete(comment.id)} title="Удалить"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <path d="M3 3L11 11M11 3L3 11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> </svg> </button> </div> )} </div> {isEditing ? ( <div className="comment-edit-area"> <textarea className="comment-textarea" value={editText} onChange={(e) => setEditText(e.target.value)} autoFocus rows={2} /> <div className="comment-edit-btns"> <button className="comment-btn comment-btn-primary" onClick={() => handleUpdate(comment.id)} disabled={!editText.trim() || sending}> Сохранить </button> <button className="comment-btn" onClick={() => { setEditingId(null); setEditText(''); }}> Отмена </button> </div> </div> ) : ( <div className="comment-text">{comment.text}</div> )} {!isReply && canEdit && !isEditing && ( <button className="comment-reply-btn" onClick={() => { setReplyTo(replyTo === comment.id ? null : comment.id); setReplyText(''); }} > Ответить </button> )} {comment.replies && comment.replies.length > 0 && ( <div className="comment-replies"> {comment.replies.map((r) => renderComment(r, true))} </div> )} {replyTo === comment.id && ( <div className="comment-reply-area"> <textarea className="comment-textarea" value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="Написать ответ..." autoFocus rows={2} /> <div className="comment-edit-btns"> <button className="comment-btn comment-btn-primary" onClick={() => handleReply(comment.id)} disabled={!replyText.trim() || sending}> Ответить </button> <button className="comment-btn" onClick={() => { setReplyTo(null); setReplyText(''); }}> Отмена </button> </div> </div> )} </div> ); }; const panelStyle = useMemo((): React.CSSProperties => { if (!anchorRect) return {}; const PANEL_WIDTH = 320; const MAX_HEIGHT = 360; const MARGIN = 6; const vw = window.innerWidth; const vh = window.innerHeight; let left = anchorRect.right + MARGIN; if (left + PANEL_WIDTH > vw - MARGIN) { left = anchorRect.left - PANEL_WIDTH - MARGIN; } if (left < MARGIN) left = MARGIN; let top = anchorRect.top; const availableBelow = vh - top - MARGIN; const maxH = Math.min(MAX_HEIGHT, availableBelow); if (maxH < 160) { top = anchorRect.bottom - MAX_HEIGHT; if (top < MARGIN) top = MARGIN; } const finalMaxH = Math.min(MAX_HEIGHT, vh - top - MARGIN); return { position: 'fixed', top, left, maxHeight: finalMaxH, transform: 'none', right: 'auto', }; }, [anchorRect]); return createPortal( <div className="comment-panel" ref={panelRef} style={panelStyle} onMouseEnter={onPanelMouseEnter} onMouseLeave={onPanelMouseLeave}> <div className="comment-panel-header"> <span className="comment-panel-title">Комментарии — {cellLabel}</span> <button className="comment-panel-close" onClick={onClose}> <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> </button> </div> <div className="comment-panel-body"> {loading ? ( <div className="comment-empty">Загрузка...</div> ) : comments.length === 0 ? ( <div className="comment-empty">Нет комментариев</div> ) : ( comments.map((c) => renderComment(c)) )} </div> {canEdit && ( <div className="comment-panel-footer"> <textarea ref={inputRef} className="comment-textarea" value={newText} onChange={(e) => setNewText(e.target.value)} placeholder="Написать комментарий..." rows={2} onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleCreate(); } }} /> <button className="comment-btn comment-btn-primary comment-send-btn" onClick={handleCreate} disabled={!newText.trim() || sending} > Отправить </button> </div> )} </div>, document.body, ); };