Display image attachments as square-cropped gallery grid

Image attachments now render in a 4-column grid with square aspect ratio
and center-cropped thumbnails. Clicking opens the existing gallery modal.
Hover reveals a trash icon; clicking it shows an inline confirmation
overlay before deleting. Non-image attachments retain the original list
layout.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 11:56:20 -05:00
parent bf3fba36f2
commit 1b55474a9b

View file

@ -1218,7 +1218,9 @@ export function IssueDetail() {
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? []; const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment); const imageAttachments = attachmentList.filter(isImageAttachment);
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
const hasAttachments = attachmentList.length > 0; const hasAttachments = attachmentList.length > 0;
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const attachmentUploadButton = ( const attachmentUploadButton = (
<> <>
<input <input
@ -1537,53 +1539,105 @@ export function IssueDetail() {
<p className="text-xs text-destructive">{attachmentError}</p> <p className="text-xs text-destructive">{attachmentError}</p>
)} )}
<div className="space-y-2"> {imageAttachments.length > 0 && (
{attachmentList.map((attachment) => ( <div className="grid grid-cols-4 gap-2">
<div key={attachment.id} className="border border-border rounded-md p-2"> {imageAttachments.map((attachment) => (
<div className="flex items-center justify-between gap-2"> <div
<a key={attachment.id}
href={attachment.contentPath} className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
target="_blank" onClick={() => {
rel="noreferrer" const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
className="text-xs hover:underline truncate" setGalleryIndex(idx >= 0 ? idx : 0);
title={attachment.originalFilename ?? attachment.id} setGalleryOpen(true);
> }}
{attachment.originalFilename ?? attachment.id} >
</a> <img
<button src={attachment.contentPath}
type="button" alt={attachment.originalFilename ?? "attachment"}
className="text-muted-foreground hover:text-destructive" className="h-full w-full object-cover"
onClick={() => deleteAttachment.mutate(attachment.id)} loading="lazy"
disabled={deleteAttachment.isPending} />
title="Delete attachment" <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
> {confirmDeleteId === attachment.id ? (
<Trash2 className="h-3.5 w-3.5" /> <div
</button> className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs text-white font-medium">Delete?</p>
<div className="flex gap-1.5">
<button
type="button"
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
onClick={(e) => {
e.stopPropagation();
deleteAttachment.mutate(attachment.id);
setConfirmDeleteId(null);
}}
disabled={deleteAttachment.isPending}
>
Yes
</button>
<button
type="button"
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
>
No
</button>
</div>
</div>
) : (
<button
type="button"
className="absolute top-1.5 right-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(attachment.id);
}}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
<p className="text-[11px] text-muted-foreground"> ))}
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB </div>
</p> )}
{isImageAttachment(attachment) && (
<button {nonImageAttachments.length > 0 && (
type="button" <div className="space-y-2">
className="block w-full text-left" {nonImageAttachments.map((attachment) => (
onClick={() => { <div key={attachment.id} className="border border-border rounded-md p-2">
const idx = imageAttachments.findIndex((a) => a.id === attachment.id); <div className="flex items-center justify-between gap-2">
setGalleryIndex(idx >= 0 ? idx : 0); <a
setGalleryOpen(true); href={attachment.contentPath}
}} target="_blank"
> rel="noreferrer"
<img className="text-xs hover:underline truncate"
src={attachment.contentPath} title={attachment.originalFilename ?? attachment.id}
alt={attachment.originalFilename ?? "attachment"} >
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10 cursor-pointer hover:opacity-80 transition-opacity" {attachment.originalFilename ?? attachment.id}
loading="lazy" </a>
/> <button
</button> type="button"
)} className="text-muted-foreground hover:text-destructive"
</div> onClick={() => deleteAttachment.mutate(attachment.id)}
))} disabled={deleteAttachment.isPending}
</div> title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
</div>
))}
</div>
)}
</div> </div>
) : null} ) : null}