fix: refresh comment attachment previews
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
f5a54f2814
commit
533d9ca856
2 changed files with 125 additions and 56 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { action, query } from "./_generated/server";
|
||||
import { action } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
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") },
|
||||
handler: async (ctx, { storageId }) => {
|
||||
const url = await ctx.storage.getUrl(storageId);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{comment.attachments.map((attachment) => (
|
||||
<CommentAttachmentCard
|
||||
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>
|
||||
)
|
||||
})}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue