diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 1cacb70..faad0a9 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -30,8 +30,11 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + hideFullscreen?: boolean; + } +>(({ className, children, hideClose, hideFullscreen, ...props }, ref) => { const [isFullscreen, setIsFullscreen] = React.useState(false); return @@ -45,17 +48,23 @@ const DialogContent = React.forwardRef< {...props} > {children} -
- {isFullscreen ? ( - setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> - ) : ( - setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> - )} - - - Close - -
+ {(!hideClose || !hideFullscreen) && ( +
+ {!hideFullscreen && ( + isFullscreen ? ( + setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> + ) : ( + setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> + ) + )} + {!hideClose && ( + + + Close + + )} +
+ )}
diff --git a/web/src/features/attachment/attachment-preview.tsx b/web/src/features/attachment/attachment-preview.tsx index 5d13f0c..fe64593 100644 --- a/web/src/features/attachment/attachment-preview.tsx +++ b/web/src/features/attachment/attachment-preview.tsx @@ -16,9 +16,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { Download, FileIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; +import { + Download, FileIcon, ZoomIn, ZoomOut, RotateCcw, + ChevronLeft, ChevronRight, X, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent } from '@/components/ui/dialog'; @@ -34,6 +37,12 @@ import { getFileConfig } from './mail-message-view'; const PREVIEWABLE_IMAGE = /^image\/(png|jpeg|gif|webp|svg\+xml)$/; const PREVIEWABLE_TEXT = /^(text\/(plain|csv|html|xml|css|javascript|markdown)|application\/(json|xml|javascript|x-httpd-php|x-sh|x-perl|x-python|x-ruby))$/; +export interface PreviewAttachment { + content_hash: string; + file_type: string; + filename: string; +} + interface AttachmentPreviewProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -42,6 +51,10 @@ interface AttachmentPreviewProps { contentHash: string; contentType: string; fileName: string; + /** Full attachment list for gallery navigation (optional). */ + attachments?: PreviewAttachment[]; + /** Index of the current attachment within `attachments`. */ + attachmentIndex?: number; } function isImagePreview(contentType: string) { @@ -64,21 +77,76 @@ export default function AttachmentPreview({ contentHash, contentType, fileName, + attachments, + attachmentIndex, }: AttachmentPreviewProps) { const { t } = useTranslation(); const [blobUrl, setBlobUrl] = useState(null); const [textContent, setTextContent] = useState(null); const [imageZoom, setImageZoom] = useState(1); + // ── Gallery state ────────────────────────────────────────────── + // When attachments list is provided, compute image-only indices for navigation. + const imageIndices = useMemo(() => { + if (!attachments) return []; + return attachments + .map((a, i) => (isImagePreview(a.file_type) ? i : -1)) + .filter((i) => i >= 0); + }, [attachments]); + + const [currentIndex, setCurrentIndex] = useState(attachmentIndex ?? 0); + + // Reset to the clicked attachment every time the dialog opens. + useEffect(() => { + if (open) { + setCurrentIndex(attachmentIndex ?? 0); + } + }, [open, attachmentIndex]); + + // Resolve which attachment to display. + const resolved = useMemo(() => { + if (attachments && currentIndex < attachments.length) { + const a = attachments[currentIndex]; + return { + contentHash: a.content_hash, + contentType: a.file_type, + fileName: a.filename, + }; + } + return { contentHash, contentType, fileName }; + }, [attachments, currentIndex, contentHash, contentType, fileName]); + + // Position within image-only list (for "3 / 12" counter). + const imagePos = imageIndices.indexOf(currentIndex); // -1 if not an image + const imageTotal = imageIndices.length; + + const goPrev = useCallback(() => { + if (imagePos > 0) setCurrentIndex(imageIndices[imagePos - 1]); + }, [imagePos, imageIndices]); + + const goNext = useCallback(() => { + if (imagePos < imageTotal - 1) setCurrentIndex(imageIndices[imagePos + 1]); + }, [imagePos, imageTotal, imageIndices]); + + // Keyboard navigation + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') goPrev(); + else if (e.key === 'ArrowRight') goNext(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, goPrev, goNext]); + + // ── Fetch preview blob ───────────────────────────────────────── const previewMutation = useMutation({ - mutationFn: () => preview_attachment(accountId, envelopeId, contentHash), + mutationFn: () => preview_attachment(accountId, envelopeId, resolved.contentHash), onSuccess: (blob) => { - if (isTextPreview(contentType)) { + if (isTextPreview(resolved.contentType)) { blob.text().then(setTextContent); } else { - // Re-wrap with the actual MIME type so browsers render PDFs/images inline - // instead of triggering a download (the HTTP response uses application/octet-stream). - const typedBlob = new Blob([blob], { type: contentType }); + const typedBlob = new Blob([blob], { type: resolved.contentType }); setBlobUrl(URL.createObjectURL(typedBlob)); } }, @@ -98,7 +166,7 @@ export default function AttachmentPreview({ setImageZoom(1); previewMutation.mutate(); } - }, [open]); + }, [open, resolved.contentHash]); useEffect(() => { return () => { @@ -107,109 +175,153 @@ export default function AttachmentPreview({ }, [blobUrl]); const handleDownload = () => { - download_attachment(accountId, envelopeId, contentHash, fileName); + download_attachment(accountId, envelopeId, resolved.contentHash, resolved.fileName); }; - const { icon, color } = useMemo(() => getFileConfig(contentType), [contentType]); + const { icon } = useMemo(() => getFileConfig(resolved.contentType), [resolved.contentType]); - const isImage = isImagePreview(contentType); - const isPdf = isPdfPreview(contentType); - const isText = isTextPreview(contentType); + const isImage = isImagePreview(resolved.contentType); + const isPdf = isPdfPreview(resolved.contentType); + const isText = isTextPreview(resolved.contentType); + const showArrows = imageTotal > 1 && isImage; return ( { - // Don't close when interacting with the PDF viewer toolbar if (isPdf) e.preventDefault(); }} > - {/* Toolbar */} -
-
-
{icon}
- - {fileName} - + {/* Toolbar — hidden for PDF (browser's native viewer has its own controls) */} + {!isPdf && ( +
+
+ {icon} + + {resolved.fileName} + + {imagePos >= 0 && imageTotal > 1 && ( + + {imagePos + 1} / {imageTotal} + + )} +
+
+ {isImage && blobUrl && ( + <> + + + + + {t('attachment_preview.zoomIn')} + + + + + + {t('attachment_preview.zoomOut')} + + + + + + {t('attachment_preview.resetZoom')} + + + + )} + + + + + {t('attachment.download')} + +
-
- {isImage && blobUrl && ( - <> - - - - - {t('attachment_preview.zoomIn')} - - - - - - {t('attachment_preview.zoomOut')} - - - - - - {t('attachment_preview.resetZoom')} - - - - )} - - - - - {t('attachment.download')} - -
-
+ )} + + {/* Close button — positioned below browser PDF toolbar */} + + + {/* Navigation arrows */} + {showArrows && ( + <> + + + + )} {/* Preview body */} -
+
{previewMutation.isPending ? ( -
-
- - - -
+
+ + +
) : isImage && blobUrl ? (
{fileName} ) : isText && textContent !== null ? ( -
+            
               {textContent}
             
) : !previewMutation.isPending ? ( -
-
- -

{t('attachment_preview.notAvailable')}

-

- {t('attachment_preview.notAvailableDesc', { - type: contentType || 'unknown', - })} -

- -
+
+ +

{t('attachment_preview.notAvailable')}

+

+ {t('attachment_preview.notAvailableDesc', { + type: resolved.contentType || 'unknown', + })} +

+
) : null}
diff --git a/web/src/features/attachment/mail-message-view.tsx b/web/src/features/attachment/mail-message-view.tsx index 338f862..674e830 100644 --- a/web/src/features/attachment/mail-message-view.tsx +++ b/web/src/features/attachment/mail-message-view.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react'; +import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog'; import useMinimalAccountList from '@/hooks/use-minimal-account-list'; import { useTranslation } from 'react-i18next'; import { NestedEmailDialog } from './nested-email-dialog'; -import AttachmentPreview from './attachment-preview'; +import AttachmentPreview, { type PreviewAttachment } from './attachment-preview'; import { EmailEnvelope } from '@/api'; @@ -124,7 +124,7 @@ export function MailMessageView({ const [threadOpen, setThreadOpen] = useState(false); const [blockRemote, setBlockRemote] = useState(true); const [hasRemoteContent, setHasRemoteContent] = useState(false); - const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null); + const [previewAttachment, setPreviewAttachment] = useState<{ attachments: PreviewAttachment[]; index: number } | null>(null); const toggleBlockRemote = () => { setBlockRemote((prev) => !prev); @@ -336,9 +336,12 @@ export function MailMessageView({ title={attachment.filename} onClick={() => setPreviewAttachment({ - content_hash: attachment.content_hash, - file_type: attachment.file_type, - filename: attachment.filename, + attachments: nonInline.map((a) => ({ + content_hash: a.content_hash, + file_type: a.file_type, + filename: a.filename, + })), + index: i, }) } > @@ -370,16 +373,6 @@ export function MailMessageView({ {formatBytes(attachment.size)} - - setPreviewAttachment({ - content_hash: attachment.content_hash, - file_type: attachment.file_type, - filename: attachment.filename, - }) - } - /> {downloadingAttachmentFileName === attachment.filename ? ( ) : ( @@ -459,15 +452,17 @@ export function MailMessageView({ fileName={nestedEmlFile?.filename || ''} content_hash={nestedEmlFile?.content_hash} /> - {previewAttachment && ( + {previewAttachment?.attachments?.[previewAttachment.index] && ( !open && setPreviewAttachment(null)} accountId={envelope.account_id} envelopeId={envelope.id} - contentHash={previewAttachment.content_hash} - contentType={previewAttachment.file_type} - fileName={previewAttachment.filename} + contentHash={previewAttachment.attachments[previewAttachment.index].content_hash} + contentType={previewAttachment.attachments[previewAttachment.index].file_type} + fileName={previewAttachment.attachments[previewAttachment.index].filename} + attachments={previewAttachment.attachments} + attachmentIndex={previewAttachment.index} /> )}
diff --git a/web/src/features/search/mail-message-view.tsx b/web/src/features/search/mail-message-view.tsx index 7575ac1..38877a1 100644 --- a/web/src/features/search/mail-message-view.tsx +++ b/web/src/features/search/mail-message-view.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react'; +import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog'; import useMinimalAccountList from '@/hooks/use-minimal-account-list'; import { useTranslation } from 'react-i18next'; import { NestedEmailDialog } from './nested-email-dialog'; -import AttachmentPreview from '@/features/attachment/attachment-preview'; +import AttachmentPreview, { type PreviewAttachment } from '@/features/attachment/attachment-preview'; interface MailMessageViewProps { @@ -132,7 +132,7 @@ export function MailMessageView({ const [threadOpen, setThreadOpen] = useState(false); const [blockRemote, setBlockRemote] = useState(true); const [hasRemoteContent, setHasRemoteContent] = useState(false); - const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null); + const [previewAttachment, setPreviewAttachment] = useState<{ attachments: PreviewAttachment[]; index: number } | null>(null); const toggleBlockRemote = () => { setBlockRemote((prev) => !prev); @@ -344,9 +344,12 @@ export function MailMessageView({ title={attachment.filename} onClick={() => setPreviewAttachment({ - content_hash: attachment.content_hash, - file_type: attachment.file_type, - filename: attachment.filename, + attachments: nonInline.map((a) => ({ + content_hash: a.content_hash, + file_type: a.file_type, + filename: a.filename, + })), + index: i, }) } > @@ -378,16 +381,6 @@ export function MailMessageView({ {formatBytes(attachment.size)} - - setPreviewAttachment({ - content_hash: attachment.content_hash, - file_type: attachment.file_type, - filename: attachment.filename, - }) - } - /> {downloadingAttachmentFileName === attachment.filename ? ( ) : ( @@ -467,15 +460,17 @@ export function MailMessageView({ fileName={nestedEmlFile?.filename || ''} content_hash={nestedEmlFile?.content_hash} /> - {previewAttachment && ( + {previewAttachment?.attachments?.[previewAttachment.index] && ( !open && setPreviewAttachment(null)} accountId={envelope.account_id} envelopeId={envelope.id} - contentHash={previewAttachment.content_hash} - contentType={previewAttachment.file_type} - fileName={previewAttachment.filename} + contentHash={previewAttachment.attachments[previewAttachment.index].content_hash} + contentType={previewAttachment.attachments[previewAttachment.index].file_type} + fileName={previewAttachment.attachments[previewAttachment.index].filename} + attachments={previewAttachment.attachments} + attachmentIndex={previewAttachment.index} /> )}