feat: enable ticket comment editing
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
533d9ca856
commit
07ff117a67
5 changed files with 340 additions and 56 deletions
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||
import { FileIcon, Trash2, X } from "lucide-react"
|
||||
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||
import { useAction, useMutation } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -21,6 +21,7 @@ import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/component
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -35,6 +36,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const { userId } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||
const updateComment = useMutation(api.tickets.updateComment)
|
||||
const [body, setBody] = useState("")
|
||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
|
|
@ -42,6 +44,52 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
||||
const [removingAttachment, setRemovingAttachment] = useState(false)
|
||||
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
|
||||
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
|
||||
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||
|
||||
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
|
||||
setEditingComment({ id: commentId, value: currentBody || "" })
|
||||
}, [])
|
||||
|
||||
const cancelEditingComment = useCallback(() => {
|
||||
setEditingComment(null)
|
||||
}, [])
|
||||
|
||||
const saveEditedComment = useCallback(
|
||||
async (commentId: string, originalBody: string) => {
|
||||
if (!editingComment || editingComment.id !== commentId) return
|
||||
if (!userId) return
|
||||
if (commentId.startsWith("temp-")) return
|
||||
|
||||
const sanitized = sanitizeEditorHtml(editingComment.value)
|
||||
if (sanitized === originalBody) {
|
||||
setEditingComment(null)
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = `edit-comment-${commentId}`
|
||||
setSavingCommentId(commentId)
|
||||
toast.loading("Salvando comentário...", { id: toastId })
|
||||
try {
|
||||
await updateComment({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
commentId: commentId as unknown as Id<"ticketComments">,
|
||||
actorId: userId as Id<"users">,
|
||||
body: sanitized,
|
||||
})
|
||||
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
|
||||
setEditingComment(null)
|
||||
toast.success("Comentário atualizado!", { id: toastId })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível atualizar o comentário.", { id: toastId })
|
||||
} finally {
|
||||
setSavingCommentId(null)
|
||||
}
|
||||
},
|
||||
[editingComment, ticket.id, updateComment, userId]
|
||||
)
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
|
|
@ -150,12 +198,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
const bodyHtml = comment.body ?? ""
|
||||
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
|
||||
const hasBody = bodyPlain.length > 0
|
||||
const commentId = String(comment.id)
|
||||
const storedBody = localBodies[commentId] ?? comment.body ?? ""
|
||||
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
|
||||
const isEditing = editingComment?.id === commentId
|
||||
const isPending = commentId.startsWith("temp-")
|
||||
const canEdit = Boolean(userId && String(comment.author.id) === userId && !isPending)
|
||||
const hasBody = bodyPlain.length > 0 || isEditing
|
||||
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div key={comment.id} className="group/comment flex gap-3">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
|
|
@ -172,9 +224,60 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
{hasBody ? (
|
||||
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||
<RichTextContent html={bodyHtml} />
|
||||
{isEditing ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
|
||||
<RichTextEditor
|
||||
value={editingComment?.value ?? ""}
|
||||
onChange={(next) =>
|
||||
setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev))
|
||||
}
|
||||
disabled={savingCommentId === commentId}
|
||||
placeholder="Edite o comentário..."
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||
onClick={cancelEditingComment}
|
||||
disabled={savingCommentId === commentId}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className={submitButtonClass}
|
||||
onClick={() => saveEditedComment(commentId, storedBody)}
|
||||
disabled={savingCommentId === commentId}
|
||||
>
|
||||
{savingCommentId === commentId ? <Spinner className="size-4 text-white" /> : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : hasBody ? (
|
||||
<div className="relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className="absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
||||
aria-label="Editar comentário"
|
||||
>
|
||||
<PencilLine className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<RichTextContent html={storedBody} />
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
<PencilLine className="size-4" />
|
||||
Adicionar conteúdo ao comentário
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
|
|
@ -331,6 +434,11 @@ function CommentAttachmentCard({
|
|||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [errored, setErrored] = useState(false)
|
||||
const hasRefreshedRef = useRef(false)
|
||||
const isImageType = useMemo(() => {
|
||||
const name = attachment.name ?? ""
|
||||
const type = attachment.type ?? ""
|
||||
return (!!type && type.startsWith("image/")) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
|
||||
}, [attachment.name, attachment.type])
|
||||
|
||||
useEffect(() => {
|
||||
setUrl(attachment.url ?? null)
|
||||
|
|
@ -355,6 +463,19 @@ function CommentAttachmentCard({
|
|||
return null
|
||||
}, [attachment.id, getFileUrl])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const fresh = await ensureUrl()
|
||||
if (!cancelled && fresh) {
|
||||
setUrl(fresh)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ensureUrl])
|
||||
|
||||
const handleImageError = useCallback(async () => {
|
||||
if (hasRefreshedRef.current) {
|
||||
setErrored(true)
|
||||
|
|
@ -382,31 +503,43 @@ function CommentAttachmentCard({
|
|||
}, [ensureUrl, url])
|
||||
|
||||
const name = attachment.name ?? ""
|
||||
const type = attachment.type ?? ""
|
||||
const isImage =
|
||||
(!!type && type.startsWith("image/")) ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
|
||||
|
||||
const showImage = isImage && url && !errored
|
||||
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
|
||||
const showImage = isImageType && url && !errored
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
||||
{showImage ? (
|
||||
<button type="button" onClick={handlePreview} className="block w-full overflow-hidden rounded-md">
|
||||
<button type="button" onClick={handlePreview} className="relative block w-full overflow-hidden rounded-md">
|
||||
{refreshing ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
|
||||
<Spinner className="size-5 text-neutral-600" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url ?? undefined} alt={name} className="h-24 w-full rounded-md object-cover" onError={handleImageError} />
|
||||
<img
|
||||
key={url ?? "no-url"}
|
||||
src={url ?? undefined}
|
||||
alt={name}
|
||||
className="h-24 w-full rounded-md object-cover"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={isImage ? handlePreview : handleDownload}
|
||||
onClick={isImageType || urlLooksImage ? handlePreview : handleDownload}
|
||||
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<FileIcon className="size-5 text-neutral-600" />
|
||||
{refreshing ? (
|
||||
<Spinner className="size-5 text-neutral-600" />
|
||||
) : isImageType || urlLooksImage ? (
|
||||
<ImageIcon className="size-5 text-neutral-600" />
|
||||
) : (
|
||||
<FileIcon className="size-5 text-neutral-600" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{refreshing ? "Gerando link..." : isImage ? "Visualizar" : url ? "Baixar" : "Gerar link"}
|
||||
{errored ? "Não foi possível carregar" : refreshing ? "Gerando link..." : url ? "Abrir" : "Gerar link"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue