mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
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:
parent
bf3fba36f2
commit
1b55474a9b
1 changed files with 100 additions and 46 deletions
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue