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,4 +1,4 @@
import { action, query } from "./_generated/server"; import { action } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
export const generateUploadUrl = action({ export const generateUploadUrl = action({
@ -8,7 +8,7 @@ export const generateUploadUrl = action({
}, },
}); });
export const getUrl = query({ export const getUrl = action({
args: { storageId: v.id("_storage") }, args: { storageId: v.id("_storage") },
handler: async (ctx, { storageId }) => { handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId); const url = await ctx.storage.getUrl(storageId);

View file

@ -1,11 +1,11 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage } from "@tabler/icons-react"
import { FileIcon, Trash2, X } from "lucide-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 // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -179,58 +179,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
) : null} ) : null}
{comment.attachments?.length ? ( {comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3"> <div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => { {comment.attachments.map((attachment) => (
const name = attachment?.name ?? "" <CommentAttachmentCard
const url = attachment?.url key={attachment.id}
const type = attachment?.type ?? "" attachment={attachment}
const isImage = onOpenPreview={(url) => setPreview(url)}
(!!type && type.startsWith("image/")) || onRequestRemoval={() =>
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) || setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.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>
)
})}
</div> </div>
) : null} ) : null}
</div> </div>
@ -341,6 +299,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Dialog> </Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}> <Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0"> <DialogContent className="max-w-3xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Visualização de anexo</DialogTitle>
</DialogHeader>
{preview ? ( {preview ? (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@ -353,3 +314,111 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Card> </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>
)
}