/
laborgit
/
laborgit-frontend
Обзор
Документация
Войти
/
laborgit
/
laborgit-frontend
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
src/components/UserActivity/Comments/CommentSectionUI.tsx
469 строк
15 KB
gdev2018
TSKLBRGTFRNT-15 upd Comment updated
16 фев 2026, 01:16
16 фев 2026, 01:16
4adce0c
Код
Авторство
О чём код?
import React, { useState } from "react"; import { Box, Card, CardContent, Avatar, TextField, List, ListItem, ListItemAvatar, ListItemText, CircularProgress, Collapse, IconButton, Badge, Typography, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import Link from "@mui/material/Link"; import CommentIcon from "@mui/icons-material/Comment"; import SendIcon from "@mui/icons-material/Send"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import { IComment } from "dto/IComment.ts"; interface CommentSectionUIProps { comments: IComment[]; isLoading?: boolean; isAddingComment?: boolean; isDeletingComment?: boolean; currentUserName?: string; currentUserAvatar?: string; isOwner: boolean; onAddComment?: (text: string) => void; onDeleteComment?: (commentId: string) => void; onEditComment?: (commentId: string, text: string) => void; onCancelEdit?: () => void; editingCommentId?: string | null; editCommentText?: string; onEditTextChange?: (text: string) => void; onStartEdit?: (comment: IComment) => void; showDeleteConfirm?: boolean; onShowDeleteConfirm?: (commentId: string | null) => void; onConfirmDelete?: (commentId: string) => void; } const formatCommentTime = (timestampInSeconds?: number) => { if (!timestampInSeconds) return "just now"; const nowInSeconds = Math.floor(Date.now() / 1000); const diffInSeconds = nowInSeconds - timestampInSeconds; if (diffInSeconds < 60) return "just now"; if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; const date = new Date(timestampInSeconds * 1000); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}`; }; const linkify = (text: string) => { const urlRegex = /(https?:\/\/|ftp:\/\/|www\.)[^\s]+/g; const parts: (string | JSX.Element)[] = []; let lastIndex = 0; let match; // Находим все ссылки в тексте while ((match = urlRegex.exec(text)) !== null) { // Добавляем текст до ссылки if (match.index > lastIndex) { parts.push(text.substring(lastIndex, match.index)); } // Добавляем саму ссылку const url = match[0]; let href = url; if (href.startsWith("www.")) { href = "http://" + href; } parts.push( <Link key={match.index} href={href} target="_blank" rel="noopener noreferrer" sx={{ color: "primary.main", textDecoration: "underline", wordBreak: "break-all", "&:hover": { textDecoration: "none" } }} > {url} </Link> ); lastIndex = match.index + url.length; } // Добавляем оставшийся текст после последней ссылки if (lastIndex < text.length) { parts.push(text.substring(lastIndex)); } return parts; }; export const CommentSectionUI: React.FC<CommentSectionUIProps> = ({ comments, isLoading = false, isAddingComment = false, isDeletingComment = false, currentUserName = "You", currentUserAvatar = "https://i.pravatar.cc/150?img=13", isOwner, onAddComment, onDeleteComment, onEditComment, onCancelEdit, editingCommentId, editCommentText = "", onEditTextChange, onStartEdit, showDeleteConfirm = false, onShowDeleteConfirm, onConfirmDelete }) => { const [showComments, setShowComments] = useState(false); const [commentText, setCommentText] = useState(""); const [pendingDeleteCommentId, setPendingDeleteCommentId] = useState<string | null>(null); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (editingCommentId && onEditComment) { onEditComment(editingCommentId, editCommentText); } else if (onAddComment) { onAddComment(commentText); setCommentText(""); } } }; const handleSaveEdit = () => { if (editingCommentId && onEditComment) { onEditComment(editingCommentId, editCommentText); } }; const handleAddCommentClick = () => { if (commentText.trim() && onAddComment) { onAddComment(commentText); setCommentText(""); } }; // Обработчик нажатия на иконку удаления const handleDeleteClick = (commentId: string) => { setPendingDeleteCommentId(commentId); onShowDeleteConfirm?.(commentId); }; // Обработчик подтверждения удаления const handleConfirmDelete = () => { if (pendingDeleteCommentId) { if (onConfirmDelete) { // Используем onConfirmDelete если он передан onConfirmDelete(pendingDeleteCommentId); } else if (onDeleteComment) { // Иначе используем onDeleteComment onDeleteComment(pendingDeleteCommentId); } setPendingDeleteCommentId(null); } }; // Обработчик отмены удаления const handleCancelDelete = () => { setPendingDeleteCommentId(null); onShowDeleteConfirm?.(null); }; const renderCommentContent = (comment: IComment) => { if (editingCommentId === comment.id) { return ( <Box sx={{ mt: 1 }}> <TextField fullWidth multiline rows={2} value={editCommentText} onChange={(e) => onEditTextChange?.(e.target.value)} onKeyPress={handleKeyPress} variant="outlined" size="small" autoFocus /> <Box sx={{ display: "flex", gap: 1, mt: 1 }}> <Button size="small" variant="contained" onClick={handleSaveEdit} disabled={!editCommentText.trim()} > Save </Button> <Button size="small" onClick={onCancelEdit}> Cancel </Button> </Box> </Box> ); } return ( <Typography variant="body2" color="text.primary" sx={{ mt: 0.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }} > {linkify(comment.name)} </Typography> ); }; const renderComment = (comment: IComment) => ( <ListItem key={comment.id} alignItems="flex-start" sx={{ borderRadius: 1, mb: 1, backgroundColor: comment.isCurrentUser ? "action.selected" : "transparent", "&:hover": { backgroundColor: "action.hover" } }} > <ListItemAvatar> <Avatar src={comment.userAvatar} sx={{ width: 36, height: 36, border: comment.isCurrentUser ? 2 : 0, borderColor: "primary.main" }} > {comment.userName?.charAt(0)} </Avatar> </ListItemAvatar> <ListItemText sx={{ ml: 1 }} primary={ <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }} > <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Typography variant="subtitle2" sx={{ fontWeight: comment.isCurrentUser ? 600 : 500, color: comment.isCurrentUser ? "primary.main" : "text.primary" }} > {comment.userName} </Typography> {comment.isCurrentUser && ( <Typography variant="caption" sx={{ backgroundColor: "primary.main", color: "white", px: 0.5, py: 0.25, borderRadius: 0.5, fontSize: "0.65rem" }} > You </Typography> )} </Box> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> <Typography variant="caption" color="text.secondary"> {(() => { const created = comment.generation; const updated = comment.updated; const isEdited = updated && created && updated > created; const timeDisplay = formatCommentTime(created); if (isEdited) { return `${formatCommentTime(updated)} (edited)`; } return timeDisplay; })()} </Typography> {!editingCommentId && (comment.isCurrentUser || isOwner) && ( <> <IconButton size="small" onClick={() => onStartEdit?.(comment)}> <EditIcon fontSize="small" /> </IconButton> <IconButton size="small" onClick={() => handleDeleteClick(comment.id)}> <DeleteIcon fontSize="small" /> </IconButton> </> )} </Box> </Box> } secondary={renderCommentContent(comment)} /> </ListItem> ); const renderDeleteDialog = () => ( <Dialog open={showDeleteConfirm} onClose={handleCancelDelete} maxWidth="xs" fullWidth> <DialogTitle>Delete Comment</DialogTitle> <DialogContent> <DialogContentText> Are you sure you want to delete this comment? This action cannot be undone. </DialogContentText> </DialogContent> <DialogActions> <Button onClick={handleCancelDelete} disabled={isDeletingComment}> Cancel </Button> <Button onClick={handleConfirmDelete} color="error" variant="contained" disabled={isDeletingComment} startIcon={isDeletingComment ? <CircularProgress size={16} /> : null} > {isDeletingComment ? "Deleting..." : "Delete"} </Button> </DialogActions> </Dialog> ); return ( <Box sx={{ mt: 4, mb: 2 }}> <Card variant="outlined" sx={{ borderColor: showComments ? "primary.main" : "divider", transition: "border-color 0.2s" }} > <CardContent sx={{ p: 0 }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, backgroundColor: showComments ? "action.hover" : "transparent", borderBottom: showComments ? 1 : 0, borderColor: "divider", cursor: "pointer", "&:hover": { backgroundColor: "action.hover" } }} onClick={() => setShowComments(!showComments)} > <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Badge badgeContent={comments.length} color="primary" max={99}> <CommentIcon color={showComments ? "primary" : "action"} /> </Badge> <Typography variant="h6" color={showComments ? "primary" : "text.primary"}> Comments </Typography> </Box> <IconButton size="small"> {showComments ? <ExpandLessIcon /> : <ExpandMoreIcon />} </IconButton> </Box> <Collapse in={showComments}> <Box sx={{ p: 2 }}> {isLoading ? ( <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> <CircularProgress size={32} /> </Box> ) : ( <> {/* Список комментариев */} {comments.length > 0 ? ( <List sx={{ mb: 2 }}>{comments.map(renderComment)}</List> ) : ( <Box sx={{ textAlign: "center", py: 4 }}> <CommentIcon sx={{ fontSize: 48, color: "action.disabled", mb: 1 }} /> <Typography variant="body1" color="text.secondary" gutterBottom> No comments yet </Typography> <Typography variant="body2" color="text.secondary"> Be the first to share your thoughts! </Typography> </Box> )} {/* Форма добавления комментария */} <Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: "divider" }}> <Typography variant="subtitle2" gutterBottom> Add a comment </Typography> <Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}> <Avatar src={currentUserAvatar} sx={{ width: 40, height: 40, mt: 0.5 }}> {currentUserName?.charAt(0) || "U"} </Avatar> <Box sx={{ flex: 1 }}> <TextField fullWidth multiline rows={3} value={commentText} onChange={(e) => setCommentText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Write your comment here..." variant="outlined" disabled={isAddingComment} helperText="Press Enter to send, Shift+Enter for new line" /> </Box> </Box> <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}> <Button variant="contained" onClick={handleAddCommentClick} disabled={!commentText.trim() || isAddingComment} startIcon={isAddingComment ? <CircularProgress size={16} /> : <SendIcon />} > {isAddingComment ? "Sending..." : "Send Comment"} </Button> </Box> </Box> </> )} </Box> </Collapse> </CardContent> </Card> {renderDeleteDialog()} </Box> ); }; export default CommentSectionUI;