fix: refresh comment attachment previews

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Factory Droid 2025-10-05 01:38:46 -03:00 committed by esdrasrenan
parent f5a54f2814
commit 533d9ca856
2 changed files with 125 additions and 56 deletions

View file

@ -1,11 +1,11 @@
"use client"
import { useMemo, useState } from "react"
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 { useMutation } from "convex/react"
import { useAction, useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -179,58 +179,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => {
const name = attachment?.name ?? ""
const url = attachment?.url
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 openRemovalModal = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name })
}
return (
<div
key={attachment.id}
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"
>
{isImage && url ? (
<button
type="button"
onClick={() => setPreview(url || null)}
className="block w-full overflow-hidden rounded-md"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<a
href={url ?? undefined}
download={name || undefined}
target="_blank"
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"
>
<FileIcon className="size-5 text-neutral-600" />
{url ? <span className="font-medium">Baixar</span> : <span>Pendente</span>}
</a>
)}
<button
type="button"
onClick={openRemovalModal}
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>
)
})}
{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>
@ -341,6 +299,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Visualização de anexo</DialogTitle>
</DialogHeader>
{preview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
@ -353,3 +314,111 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</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)
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])
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) {
window.open(target, "_blank", "noopener,noreferrer")
}
}, [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
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">
{/* 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} />
</button>
) : (
<button
type="button"
onClick={isImage ? 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" />
<span className="font-medium">
{refreshing ? "Gerando link..." : isImage ? "Visualizar" : url ? "Baixar" : "Gerar link"}
</span>
</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>
)
}