diff --git a/src/pages/taskmanager/components/task-detail/comment/CommentAttachments.tsx b/src/pages/taskmanager/components/task-detail/comment/CommentAttachments.tsx new file mode 100644 index 0000000..573e36f --- /dev/null +++ b/src/pages/taskmanager/components/task-detail/comment/CommentAttachments.tsx @@ -0,0 +1,128 @@ +import { CloseCircle, Document, Paperclip2 } from "iconsax-react"; +import { useEffect, useState, type FC } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { isImageAttachment } from "../attachment/isImageAttachment"; + +const getFileName = (url: string) => { + const path = url.split("?")[0].split("#")[0]; + return decodeURIComponent(path.split("/").pop() || url); +}; + +type Props = { + attachments: string[]; + onRemove?: (index: number) => void; +}; + +const CommentAttachments: FC = ({ attachments, onRemove }) => { + const { t } = useTranslation("global"); + const [previewUrl, setPreviewUrl] = useState(null); + + useEffect(() => { + if (!previewUrl) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setPreviewUrl(null); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [previewUrl]); + + if (attachments.length === 0) return null; + + return ( + <> +
+ {attachments.map((url, index) => { + const isImage = isImageAttachment(url); + const title = getFileName(url); + + return ( +
+ {isImage ? ( + + ) : ( +
+ +
+ )} + + {isImage ? ( + + ) : ( + + + {t("attach")} {index + 1} + + )} + + {onRemove ? ( + + ) : null} +
+ ); + })} +
+ + {previewUrl && + createPortal( +
+ + + {getFileName(previewUrl)} +
+ , + document.body, + )} + + ); +}; + +export default CommentAttachments; diff --git a/src/pages/taskmanager/components/task-detail/comment/CommentItem.tsx b/src/pages/taskmanager/components/task-detail/comment/CommentItem.tsx index 8c03734..7943ec8 100644 --- a/src/pages/taskmanager/components/task-detail/comment/CommentItem.tsx +++ b/src/pages/taskmanager/components/task-detail/comment/CommentItem.tsx @@ -1,13 +1,15 @@ -import { Edit2 } from "iconsax-react"; +import { CloseCircle, Edit2, Paperclip2 } from "iconsax-react"; import moment from "moment-jalaali"; -import { type FC, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "../../../../../components/Toast"; import TrashWithConfrim from "../../../../../components/TrashWithConfrim"; import { ErrorType } from "../../../../../helpers/types"; +import { useMultiUpload } from "../../../../service/hooks/useServiceData"; import { useDeleteTaskComment, useUpdateTaskComment } from "../../../task/hooks/useTaskCommentData"; import type { TaskCommentType } from "../../../task/types/TaskCommentTypes"; import UserAvatar from "../../UserAvatar"; +import CommentAttachments from "./CommentAttachments"; type Props = { comment: TaskCommentType; @@ -16,29 +18,86 @@ type Props = { const CommentItem: FC = ({ comment, taskId }) => { const { t } = useTranslation("global"); + const fileInputRef = useRef(null); const [isEditing, setIsEditing] = useState(false); const [content, setContent] = useState(comment.content); + const [attachments, setAttachments] = useState(comment.attachments ?? []); + const [pendingFiles, setPendingFiles] = useState([]); const updateComment = useUpdateTaskComment(); const deleteComment = useDeleteTaskComment(); + const multiUpload = useMultiUpload(); + + useEffect(() => { + setContent(comment.content); + setAttachments(comment.attachments ?? []); + }, [comment.content, comment.attachments]); const authorName = comment.user ? `${comment.user.firstName} ${comment.user.lastName}`.trim() : t("taskmanager.task_detail.unknown_user"); - const handleSave = () => { + const isSubmitting = updateComment.isPending || multiUpload.isPending; + + const handleFilesSelected = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []); + if (selected.length === 0) return; + + setPendingFiles((prev) => [...prev, ...selected]); + e.target.value = ""; + }; + + const handleSave = async () => { const trimmedContent = content.trim(); - if (!trimmedContent || trimmedContent === comment.content) { + const originalAttachments = comment.attachments ?? []; + const contentUnchanged = trimmedContent === comment.content; + const attachmentsUnchanged = + pendingFiles.length === 0 && + attachments.length === originalAttachments.length && + attachments.every((url, index) => url === originalAttachments[index]); + + if (contentUnchanged && attachmentsUnchanged) { setIsEditing(false); setContent(comment.content); + setAttachments(originalAttachments); + setPendingFiles([]); return; } + if (!trimmedContent && attachments.length === 0 && pendingFiles.length === 0) { + return; + } + + let nextAttachments = [...attachments]; + + if (pendingFiles.length > 0) { + const formData = new FormData(); + pendingFiles.forEach((file) => formData.append("files", file)); + + try { + const uploadResult = await multiUpload.mutateAsync(formData); + const uploadedUrls: string[] = + uploadResult?.data?.map((item: { url: string }) => item?.url).filter(Boolean) ?? []; + nextAttachments = [...nextAttachments, ...uploadedUrls]; + } catch (error) { + toast((error as ErrorType).response?.data?.error.message[0], "error"); + return; + } + } + updateComment.mutate( - { id: comment.id, params: { content: trimmedContent }, taskId }, + { + id: comment.id, + params: { + content: trimmedContent, + attachments: nextAttachments, + }, + taskId, + }, { onSuccess: () => { toast(t("success"), "success"); setIsEditing(false); + setPendingFiles([]); }, onError: (error: ErrorType) => { toast(error.response?.data?.error.message[0], "error"); @@ -49,6 +108,8 @@ const CommentItem: FC = ({ comment, taskId }) => { const handleCancel = () => { setContent(comment.content); + setAttachments(comment.attachments ?? []); + setPendingFiles([]); setIsEditing(false); }; @@ -88,11 +149,42 @@ const CommentItem: FC = ({ comment, taskId }) => { className="w-full bg-white/50 rounded-lg px-3 py-2 text-xs outline-none resize-none" autoFocus /> -
+ + setAttachments((prev) => prev.filter((_, i) => i !== index))} + /> + + {pendingFiles.length > 0 ? ( +
+ {pendingFiles.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+ ) : null} + +
+ +
) : ( -
{comment.content}
+ <> +
{comment.content}
+ + )} diff --git a/src/pages/taskmanager/components/task-detail/comment/List.tsx b/src/pages/taskmanager/components/task-detail/comment/List.tsx index c4f80bb..9d5c90a 100644 --- a/src/pages/taskmanager/components/task-detail/comment/List.tsx +++ b/src/pages/taskmanager/components/task-detail/comment/List.tsx @@ -1,7 +1,9 @@ -import { type FC, useState } from "react"; +import { CloseCircle, Paperclip2 } from "iconsax-react"; +import { type FC, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "../../../../../components/Toast"; import { ErrorType } from "../../../../../helpers/types"; +import { useMultiUpload } from "../../../../service/hooks/useServiceData"; import { useCreateTaskComment, useGetTaskComments } from "../../../task/hooks/useTaskCommentData"; import CommentItem from "./CommentItem"; @@ -12,23 +14,59 @@ type Props = { const CommentList: FC = ({ taskId, enabled = true }) => { const { t } = useTranslation("global"); + const fileInputRef = useRef(null); const [content, setContent] = useState(""); + const [files, setFiles] = useState([]); const getComments = useGetTaskComments(taskId, enabled); const createComment = useCreateTaskComment(); + const multiUpload = useMultiUpload(); const comments = getComments.data?.data ?? []; const trimmedContent = content.trim(); - const isSubmitDisabled = createComment.isPending || trimmedContent.length === 0; + const isSubmitting = createComment.isPending || multiUpload.isPending; + const isSubmitDisabled = isSubmitting || (trimmedContent.length === 0 && files.length === 0); - const handleSubmit = () => { - if (!trimmedContent) return; + const handleFilesSelected = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []); + if (selected.length === 0) return; + + setFiles((prev) => [...prev, ...selected]); + e.target.value = ""; + }; + + const handleRemoveFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + if (isSubmitDisabled) return; + + let attachments: string[] | undefined; + + if (files.length > 0) { + const formData = new FormData(); + files.forEach((file) => formData.append("files", file)); + + try { + const uploadResult = await multiUpload.mutateAsync(formData); + attachments = uploadResult?.data?.map((item: { url: string }) => item?.url).filter(Boolean); + } catch (error) { + toast((error as ErrorType).response?.data?.error.message[0], "error"); + return; + } + } createComment.mutate( - { content: trimmedContent, taskId }, + { + content: trimmedContent, + taskId, + ...(attachments?.length ? { attachments } : {}), + }, { onSuccess: () => { toast(t("success"), "success"); setContent(""); + setFiles([]); }, onError: (error: ErrorType) => { toast(error.response?.data?.error.message[0], "error"); @@ -42,21 +80,61 @@ const CommentList: FC = ({ taskId, enabled = true }) => {
{t("taskmanager.task_detail.comments")}
- +
+ + +
+