sistema-de-chamados/src/components/tickets/ticket-comments.rich.tsx

690 lines
32 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { Download, FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
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
}
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold tracking-wide text-white"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth()
const normalizedRole = role ?? null
const canSeeInternalComments = normalizedRole === "admin" || normalizedRole === "agent"
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 attachmentsToSendTotalBytes = useMemo(
() => attachmentsToSend.reduce((acc, item) => acc + (item.size ?? 0), 0),
[attachmentsToSend]
)
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
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 templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
: "skip"
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
| { id: string; title: string; body: string }[]
| undefined
const templates = templatesResult ?? []
const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined
const canUseTemplates = Boolean(convexUserId && isStaff)
const insertTemplateIntoBody = (html: string) => {
const sanitized = sanitizeEditorHtml(html)
setBody((current) => {
if (!current) return sanitized
const merged = `${current}<p><br /></p>${sanitized}`
return sanitizeEditorHtml(merged)
})
}
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 (!convexUserId) 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: convexUserId 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, convexUserId]
)
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId) return
const now = new Date()
const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC"
const attachments = attachmentsToSend.map((item) => ({ ...item }))
const previewsToRevoke = attachments
.map((attachment) => attachment.previewUrl)
.filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:")))
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility: selectedVisibility,
body: sanitizeEditorHtml(body),
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
type: attachment.type,
url: attachment.previewUrl,
})),
createdAt: now,
updatedAt: now,
}
setPending((current) => [optimistic, ...current])
setBody("")
setAttachmentsToSend([])
toast.loading("Enviando comentário...", { id: "comment" })
try {
const payload = attachments.map((attachment) => ({
storageId: attachment.storageId as unknown as Id<"_storage">,
name: attachment.name,
size: attachment.size,
type: attachment.type,
}))
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: selectedVisibility,
body: optimistic.body,
attachments: payload,
})
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
previewsToRevoke.forEach((previewUrl) => {
try {
URL.revokeObjectURL(previewUrl)
} catch (error) {
console.error("Failed to revoke preview URL", error)
}
})
}
async function handleRemoveAttachment() {
if (!attachmentToRemove || !convexUserId) return
setRemovingAttachment(true)
toast.loading("Removendo anexo...", { id: "remove-attachment" })
try {
await removeAttachment({
ticketId: ticket.id as unknown as Id<"tickets">,
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
actorId: convexUserId as Id<"users">,
})
toast.success("Anexo removido.", { id: "remove-attachment" })
setAttachmentToRemove(null)
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" })
} finally {
setRemovingAttachment(false)
}
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconMessage className="size-5 text-neutral-900" /> Comentários
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessage className="size-5 text-neutral-900" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
commentsAll.map((comment) => {
const initials = comment.author.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
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(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments
const isPublic = comment.visibility === "PUBLIC"
const containerClass = isPublic
? "group/comment flex gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/80 px-3 py-3 shadow-[0_0_0_1px_rgba(217,119,6,0.15)]"
: "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
const bodyClass = isPublic
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
const bodyEditButtonClass = isPublic
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "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"
const addContentButtonClass = isPublic
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
return (
<div key={comment.id} className={containerClass}>
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" && canSeeInternalComments ? (
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
) : null}
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
{isInternal ? (
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário interno visível apenas para administradores e agentes
</span>
) : comment.visibility === "PUBLIC" ? (
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário visível para o cliente
</span>
) : null}
{isEditing ? (
<div
className={
isInternal
? "rounded-xl border border-amber-200/80 bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "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={bodyClass}>
{canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className={bodyEditButtonClass}
aria-label="Editar comentário"
>
<PencilLine className="size-3.5" />
</button>
) : null}
<RichTextContent html={storedBody} />
</div>
) : canEdit ? (
<div
className={
isInternal
? "rounded-xl border border-dashed border-amber-300 bg-amber-50/60 px-3 py-2 text-sm text-amber-700"
: "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={addContentButtonClass}
>
<PencilLine className="size-4" />
Adicionar conteúdo ao comentário
</button>
</div>
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => (
<CommentAttachmentCard
key={attachment.id}
attachment={attachment}
onOpenPreview={(url) => setPreview(url)}
onRequestRemoval={() =>
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.name })
}
/>
))}
</div>
) : null}
</div>
</div>
)
})
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
currentFileCount={attachmentsToSend.length}
currentTotalBytes={attachmentsToSendTotalBytes}
/>
{attachmentsToSend.length > 0 ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{attachmentsToSend.map((attachment, index) => {
const name = attachment.name
const previewUrl = attachment.previewUrl
const isImage =
(attachment.type ?? "").startsWith("image/") ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name)
return (
<div key={`${attachment.storageId}-${index}`} className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5">
{isImage && previewUrl ? (
<button
type="button"
onClick={() => setPreview(previewUrl || null)}
className="block w-full overflow-hidden rounded-md"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
<FileIcon className="size-4" />
<span className="line-clamp-2 px-2 text-center">{name}</span>
</div>
)}
<button
type="button"
onClick={() =>
setAttachmentsToSend((prev) => {
const next = [...prev]
const removed = next.splice(index, 1)[0]
if (removed?.previewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(removed.previewUrl)
}
return next
})
}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
aria-label={`Remover ${name}`}
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{name}
</div>
</div>
)
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
{canUseTemplates ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
disabled={templatesLoading}
>
<IconFileText className="size-4" />
Inserir template
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{templatesLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-4" />
Carregando templates...
</div>
) : templates.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">
Nenhum template disponível. Cadastre novos em configurações.
</div>
) : (
templates.map((template) => (
<DropdownMenuItem
key={template.id}
className="flex flex-col items-start whitespace-normal py-2"
onSelect={() => insertTemplateIntoBody(template.body)}
>
<span className="text-sm font-medium text-neutral-800">{template.title}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex items-center gap-2">
Visibilidade:
<Select
value={visibility}
onValueChange={(value) => {
if (!canSeeInternalComments) return
setVisibility(value as "PUBLIC" | "INTERNAL")
}}
disabled={!canSeeInternalComments}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
{canSeeInternalComments ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" size="sm" className={submitButtonClass}>
Enviar
</Button>
</div>
</form>
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
<DialogContent className="max-w-sm space-y-4">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAttachmentToRemove(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
disabled={removingAttachment}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleRemoveAttachment}
disabled={removingAttachment}
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
>
<Trash2 className="size-4" />
{removingAttachment ? "Removendo..." : "Excluir"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl border border-slate-200 p-0">
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
<DialogTitle className="text-base font-semibold text-neutral-800">Visualização do anexo</DialogTitle>
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
<X className="size-4" />
</DialogClose>
</DialogHeader>
{preview ? (
<div className="rounded-b-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt="Preview do anexo" className="h-auto w-full rounded-b-2xl" />
</div>
) : null}
</DialogContent>
</Dialog>
</CardContent>
</Card>
)
}
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
function CommentAttachmentCard({
attachment,
onOpenPreview,
onRequestRemoval,
}: {
attachment: CommentAttachment
onOpenPreview: (url: string) => void
onRequestRemoval: () => void
}) {
const getFileUrl = useAction(api.files.getUrl)
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
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)
setErrored(false)
hasRefreshedRef.current = false
}, [attachment.id, attachment.url])
const ensureUrl = useCallback(async () => {
try {
setRefreshing(true)
const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> })
if (fresh) {
setUrl(fresh)
setErrored(false)
return fresh
}
} catch (error) {
console.error("Failed to refresh attachment URL", error)
} finally {
setRefreshing(false)
}
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)
return
}
hasRefreshedRef.current = true
const fresh = await ensureUrl()
if (!fresh) {
setErrored(true)
}
}, [ensureUrl])
const handlePreview = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
onOpenPreview(target)
}
}, [ensureUrl, onOpenPreview, url])
const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (!target) return
try {
const link = document.createElement("a")
link.href = target
link.download = attachment.name ?? "anexo"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error("Failed to download attachment", error)
window.open(target, "_blank", "noopener,noreferrer")
}
}, [attachment.name, ensureUrl, url])
const name = attachment.name ?? ""
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="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
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={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}
>
{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">
{errored ? "Não foi possível carregar" : refreshing ? "Gerando link..." : url ? "Abrir" : "Gerar link"}
</span>
</button>
)}
<button
type="button"
onClick={handleDownload}
aria-label={`Baixar ${name}`}
className="absolute left-1.5 top-1.5 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 focus-visible:opacity-100 group-hover:opacity-100"
>
<Download className="size-3.5" />
</button>
<button
type="button"
onClick={onRequestRemoval}
aria-label={`Remover ${name}`}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">{name}</div>
</div>
)
}