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";
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue