attachment comment

This commit is contained in:
hamid zarghami
2026-07-25 15:49:02 +03:30
parent 77475f09cd
commit f5da44d9cf
4 changed files with 343 additions and 23 deletions
@@ -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<Props> = ({ attachments, onRemove }) => {
const { t } = useTranslation("global");
const [previewUrl, setPreviewUrl] = useState<string | null>(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 (
<>
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((url, index) => {
const isImage = isImageAttachment(url);
const title = getFileName(url);
return (
<div
key={`${url}-${index}`}
className="relative flex items-center gap-2 max-w-full rounded-lg bg-white/60 px-2 py-1.5"
>
{isImage ? (
<button
type="button"
onClick={() => setPreviewUrl(url)}
className="shrink-0 size-8 rounded-md overflow-hidden bg-white outline-none"
>
<img src={url} alt={title} className="size-full object-cover" />
</button>
) : (
<div className="shrink-0 size-8 rounded-md bg-white flex items-center justify-center">
<Document size={16} color="#292D32" />
</div>
)}
{isImage ? (
<button
type="button"
onClick={() => setPreviewUrl(url)}
className="text-[11px] text-[#0047FF] underline truncate max-w-[140px] outline-none"
>
{title}
</button>
) : (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-[#0047FF] underline truncate max-w-[140px] flex items-center gap-1"
>
<Paperclip2 size={12} color="#0047FF" />
{t("attach")} {index + 1}
</a>
)}
{onRemove ? (
<button
type="button"
onClick={() => onRemove(index)}
className="shrink-0 outline-none"
aria-label={t("cancel")}
>
<CloseCircle size={14} color="#FF0000" />
</button>
) : null}
</div>
);
})}
</div>
{previewUrl &&
createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4" role="dialog" aria-modal="true">
<button
type="button"
aria-label={t("cancel")}
className="absolute inset-0 bg-black/70 outline-none"
onClick={() => setPreviewUrl(null)}
/>
<div className="relative z-10 max-w-[90vw] max-h-[90vh]">
<button
type="button"
aria-label={t("cancel")}
onClick={() => setPreviewUrl(null)}
className="absolute -top-3 -left-3 size-9 rounded-full bg-white flex items-center justify-center shadow outline-none"
>
<CloseCircle size={22} color="#292D32" />
</button>
<img
src={previewUrl}
alt={getFileName(previewUrl)}
className="max-w-[90vw] max-h-[90vh] rounded-2xl object-contain bg-white"
/>
</div>
</div>,
document.body,
)}
</>
);
};
export default CommentAttachments;
@@ -1,13 +1,15 @@
import { Edit2 } from "iconsax-react"; import { CloseCircle, Edit2, Paperclip2 } from "iconsax-react";
import moment from "moment-jalaali"; 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 { useTranslation } from "react-i18next";
import { toast } from "../../../../../components/Toast"; import { toast } from "../../../../../components/Toast";
import TrashWithConfrim from "../../../../../components/TrashWithConfrim"; import TrashWithConfrim from "../../../../../components/TrashWithConfrim";
import { ErrorType } from "../../../../../helpers/types"; import { ErrorType } from "../../../../../helpers/types";
import { useMultiUpload } from "../../../../service/hooks/useServiceData";
import { useDeleteTaskComment, useUpdateTaskComment } from "../../../task/hooks/useTaskCommentData"; import { useDeleteTaskComment, useUpdateTaskComment } from "../../../task/hooks/useTaskCommentData";
import type { TaskCommentType } from "../../../task/types/TaskCommentTypes"; import type { TaskCommentType } from "../../../task/types/TaskCommentTypes";
import UserAvatar from "../../UserAvatar"; import UserAvatar from "../../UserAvatar";
import CommentAttachments from "./CommentAttachments";
type Props = { type Props = {
comment: TaskCommentType; comment: TaskCommentType;
@@ -16,29 +18,86 @@ type Props = {
const CommentItem: FC<Props> = ({ comment, taskId }) => { const CommentItem: FC<Props> = ({ comment, taskId }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(comment.content); const [content, setContent] = useState(comment.content);
const [attachments, setAttachments] = useState<string[]>(comment.attachments ?? []);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const updateComment = useUpdateTaskComment(); const updateComment = useUpdateTaskComment();
const deleteComment = useDeleteTaskComment(); const deleteComment = useDeleteTaskComment();
const multiUpload = useMultiUpload();
useEffect(() => {
setContent(comment.content);
setAttachments(comment.attachments ?? []);
}, [comment.content, comment.attachments]);
const authorName = comment.user const authorName = comment.user
? `${comment.user.firstName} ${comment.user.lastName}`.trim() ? `${comment.user.firstName} ${comment.user.lastName}`.trim()
: t("taskmanager.task_detail.unknown_user"); : t("taskmanager.task_detail.unknown_user");
const handleSave = () => { const isSubmitting = updateComment.isPending || multiUpload.isPending;
const handleFilesSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
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(); 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); setIsEditing(false);
setContent(comment.content); setContent(comment.content);
setAttachments(originalAttachments);
setPendingFiles([]);
return; 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( updateComment.mutate(
{ id: comment.id, params: { content: trimmedContent }, taskId }, {
id: comment.id,
params: {
content: trimmedContent,
attachments: nextAttachments,
},
taskId,
},
{ {
onSuccess: () => { onSuccess: () => {
toast(t("success"), "success"); toast(t("success"), "success");
setIsEditing(false); setIsEditing(false);
setPendingFiles([]);
}, },
onError: (error: ErrorType) => { onError: (error: ErrorType) => {
toast(error.response?.data?.error.message[0], "error"); toast(error.response?.data?.error.message[0], "error");
@@ -49,6 +108,8 @@ const CommentItem: FC<Props> = ({ comment, taskId }) => {
const handleCancel = () => { const handleCancel = () => {
setContent(comment.content); setContent(comment.content);
setAttachments(comment.attachments ?? []);
setPendingFiles([]);
setIsEditing(false); setIsEditing(false);
}; };
@@ -88,11 +149,42 @@ const CommentItem: FC<Props> = ({ comment, taskId }) => {
className="w-full bg-white/50 rounded-lg px-3 py-2 text-xs outline-none resize-none" className="w-full bg-white/50 rounded-lg px-3 py-2 text-xs outline-none resize-none"
autoFocus autoFocus
/> />
<div className="flex gap-2 mt-2">
<CommentAttachments
attachments={attachments}
onRemove={(index) => setAttachments((prev) => prev.filter((_, i) => i !== index))}
/>
{pendingFiles.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="flex items-center gap-1.5 max-w-full rounded-lg bg-white/60 px-2 py-1"
>
<span className="text-[11px] truncate max-w-[160px]">{file.name}</span>
<button
type="button"
onClick={() => setPendingFiles((prev) => prev.filter((_, i) => i !== index))}
disabled={isSubmitting}
className="shrink-0 outline-none disabled:opacity-50"
aria-label={t("cancel")}
>
<CloseCircle size={14} color="#FF0000" />
</button>
</div>
))}
</div>
) : null}
<div className="flex gap-2 mt-2 items-center">
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={updateComment.isPending || !content.trim()} disabled={
isSubmitting ||
(!content.trim() && attachments.length === 0 && pendingFiles.length === 0)
}
className="h-7 px-3 rounded-lg bg-[#292D32] text-white text-[11px] disabled:opacity-50" className="h-7 px-3 rounded-lg bg-[#292D32] text-white text-[11px] disabled:opacity-50"
> >
{t("save")} {t("save")}
@@ -100,15 +192,34 @@ const CommentItem: FC<Props> = ({ comment, taskId }) => {
<button <button
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
disabled={updateComment.isPending} disabled={isSubmitting}
className="h-7 px-3 rounded-lg bg-white/50 text-[#292D32] text-[11px] border border-white" className="h-7 px-3 rounded-lg bg-white/50 text-[#292D32] text-[11px] border border-white"
> >
{t("cancel")} {t("cancel")}
</button> </button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
className="size-7 rounded-lg bg-white/70 flex items-center justify-center disabled:opacity-50"
aria-label={t("select_file")}
>
<Paperclip2 size={16} color="#292D32" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFilesSelected}
/>
</div> </div>
</div> </div>
) : ( ) : (
<>
<div className="mt-1 text-xs leading-6 whitespace-pre-line break-words">{comment.content}</div> <div className="mt-1 text-xs leading-6 whitespace-pre-line break-words">{comment.content}</div>
<CommentAttachments attachments={comment.attachments ?? []} />
</>
)} )}
</div> </div>
@@ -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 { useTranslation } from "react-i18next";
import { toast } from "../../../../../components/Toast"; import { toast } from "../../../../../components/Toast";
import { ErrorType } from "../../../../../helpers/types"; import { ErrorType } from "../../../../../helpers/types";
import { useMultiUpload } from "../../../../service/hooks/useServiceData";
import { useCreateTaskComment, useGetTaskComments } from "../../../task/hooks/useTaskCommentData"; import { useCreateTaskComment, useGetTaskComments } from "../../../task/hooks/useTaskCommentData";
import CommentItem from "./CommentItem"; import CommentItem from "./CommentItem";
@@ -12,23 +14,59 @@ type Props = {
const CommentList: FC<Props> = ({ taskId, enabled = true }) => { const CommentList: FC<Props> = ({ taskId, enabled = true }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const fileInputRef = useRef<HTMLInputElement>(null);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [files, setFiles] = useState<File[]>([]);
const getComments = useGetTaskComments(taskId, enabled); const getComments = useGetTaskComments(taskId, enabled);
const createComment = useCreateTaskComment(); const createComment = useCreateTaskComment();
const multiUpload = useMultiUpload();
const comments = getComments.data?.data ?? []; const comments = getComments.data?.data ?? [];
const trimmedContent = content.trim(); 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 = () => { const handleFilesSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!trimmedContent) return; 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( createComment.mutate(
{ content: trimmedContent, taskId }, {
content: trimmedContent,
taskId,
...(attachments?.length ? { attachments } : {}),
},
{ {
onSuccess: () => { onSuccess: () => {
toast(t("success"), "success"); toast(t("success"), "success");
setContent(""); setContent("");
setFiles([]);
}, },
onError: (error: ErrorType) => { onError: (error: ErrorType) => {
toast(error.response?.data?.error.message[0], "error"); toast(error.response?.data?.error.message[0], "error");
@@ -42,21 +80,61 @@ const CommentList: FC<Props> = ({ taskId, enabled = true }) => {
<div className="text-sm font-bold">{t("taskmanager.task_detail.comments")}</div> <div className="text-sm font-bold">{t("taskmanager.task_detail.comments")}</div>
<div className="mt-3 relative bg-white/25 rounded-xl"> <div className="mt-3 relative bg-white/25 rounded-xl">
<div className="absolute top-2 left-2 z-10 flex items-center gap-1.5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
className="size-7 rounded-lg bg-white/70 flex items-center justify-center disabled:opacity-50"
aria-label={t("select_file")}
>
<Paperclip2 size={16} color="#292D32" />
</button>
<button <button
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitDisabled} disabled={isSubmitDisabled}
className="absolute top-2 left-2 z-10 h-7 px-2.5 rounded-lg bg-[#292D32] text-white text-[11px] disabled:opacity-50" className="h-7 px-2.5 rounded-lg bg-[#292D32] text-white text-[11px] disabled:opacity-50"
> >
{t("taskmanager.task_detail.add_comment")} {t("taskmanager.task_detail.add_comment")}
</button> </button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFilesSelected}
/>
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder={t("taskmanager.task_detail.comment_placeholder")} placeholder={t("taskmanager.task_detail.comment_placeholder")}
rows={3} rows={3}
className="w-full bg-transparent rounded-xl px-4 pt-4 pb-3 pl-20 text-xs outline-none resize-none placeholder:text-description" className="w-full bg-transparent rounded-xl px-4 pt-4 pb-3 pl-28 text-xs outline-none resize-none placeholder:text-description"
/> />
{files.length > 0 ? (
<div className="px-3 pb-3 flex flex-wrap gap-2">
{files.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="flex items-center gap-1.5 max-w-full rounded-lg bg-white/60 px-2 py-1"
>
<span className="text-[11px] truncate max-w-[160px]">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(index)}
disabled={isSubmitting}
className="shrink-0 outline-none disabled:opacity-50"
aria-label={t("cancel")}
>
<CloseCircle size={14} color="#FF0000" />
</button>
</div>
))}
</div>
) : null}
</div> </div>
{getComments.isPending ? ( {getComments.isPending ? (
@@ -13,6 +13,7 @@ export type TaskCommentType = {
taskId: string; taskId: string;
userId?: string; userId?: string;
user?: TaskCommentAuthorType; user?: TaskCommentAuthorType;
attachments?: string[];
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
}; };
@@ -20,10 +21,12 @@ export type TaskCommentType = {
export type CreateTaskCommentType = { export type CreateTaskCommentType = {
content: string; content: string;
taskId: string; taskId: string;
attachments?: string[];
}; };
export type UpdateTaskCommentType = { export type UpdateTaskCommentType = {
content: string; content: string;
attachments?: string[];
}; };
export interface GetTaskCommentsResponse extends IResponse<TaskCommentType[]> { export interface GetTaskCommentsResponse extends IResponse<TaskCommentType[]> {