mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: fullscreen attachment gallery with image navigation
This commit is contained in:
@@ -30,8 +30,11 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => {
|
hideClose?: boolean;
|
||||||
|
hideFullscreen?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, hideClose, hideFullscreen, ...props }, ref) => {
|
||||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||||
return <DialogPortal>
|
return <DialogPortal>
|
||||||
<DialogOverlay>
|
<DialogOverlay>
|
||||||
@@ -45,17 +48,23 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{(!hideClose || !hideFullscreen) && (
|
||||||
<div className='absolute right-4 top-4 flex items-center gap-2'>
|
<div className='absolute right-4 top-4 flex items-center gap-2'>
|
||||||
{isFullscreen ? (
|
{!hideFullscreen && (
|
||||||
|
isFullscreen ? (
|
||||||
<Minimize onClick={() => 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' />
|
<Minimize onClick={() => 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' />
|
||||||
) : (
|
) : (
|
||||||
<Maximize onClick={() => 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' />
|
<Maximize onClick={() => 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 && (
|
||||||
<DialogPrimitive.Close 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'>
|
<DialogPrimitive.Close 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'>
|
||||||
<X className='h-4 w-4' />
|
<X className='h-4 w-4' />
|
||||||
<span className='sr-only'>Close</span>
|
<span className='sr-only'>Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -16,9 +16,12 @@
|
|||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
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_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))$/;
|
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 {
|
interface AttachmentPreviewProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -42,6 +51,10 @@ interface AttachmentPreviewProps {
|
|||||||
contentHash: string;
|
contentHash: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
fileName: 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) {
|
function isImagePreview(contentType: string) {
|
||||||
@@ -64,21 +77,76 @@ export default function AttachmentPreview({
|
|||||||
contentHash,
|
contentHash,
|
||||||
contentType,
|
contentType,
|
||||||
fileName,
|
fileName,
|
||||||
|
attachments,
|
||||||
|
attachmentIndex,
|
||||||
}: AttachmentPreviewProps) {
|
}: AttachmentPreviewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
const [textContent, setTextContent] = useState<string | null>(null);
|
const [textContent, setTextContent] = useState<string | null>(null);
|
||||||
const [imageZoom, setImageZoom] = useState(1);
|
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({
|
const previewMutation = useMutation({
|
||||||
mutationFn: () => preview_attachment(accountId, envelopeId, contentHash),
|
mutationFn: () => preview_attachment(accountId, envelopeId, resolved.contentHash),
|
||||||
onSuccess: (blob) => {
|
onSuccess: (blob) => {
|
||||||
if (isTextPreview(contentType)) {
|
if (isTextPreview(resolved.contentType)) {
|
||||||
blob.text().then(setTextContent);
|
blob.text().then(setTextContent);
|
||||||
} else {
|
} else {
|
||||||
// Re-wrap with the actual MIME type so browsers render PDFs/images inline
|
const typedBlob = new Blob([blob], { type: resolved.contentType });
|
||||||
// instead of triggering a download (the HTTP response uses application/octet-stream).
|
|
||||||
const typedBlob = new Blob([blob], { type: contentType });
|
|
||||||
setBlobUrl(URL.createObjectURL(typedBlob));
|
setBlobUrl(URL.createObjectURL(typedBlob));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,7 +166,7 @@ export default function AttachmentPreview({
|
|||||||
setImageZoom(1);
|
setImageZoom(1);
|
||||||
previewMutation.mutate();
|
previewMutation.mutate();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, resolved.contentHash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -107,33 +175,41 @@ export default function AttachmentPreview({
|
|||||||
}, [blobUrl]);
|
}, [blobUrl]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
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 isImage = isImagePreview(resolved.contentType);
|
||||||
const isPdf = isPdfPreview(contentType);
|
const isPdf = isPdfPreview(resolved.contentType);
|
||||||
const isText = isTextPreview(contentType);
|
const isText = isTextPreview(resolved.contentType);
|
||||||
|
const showArrows = imageTotal > 1 && isImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[calc(100vw-2rem)] max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
|
className="w-screen h-screen max-w-none rounded-none p-0 gap-0 border-0 bg-slate-700/10"
|
||||||
|
hideClose
|
||||||
|
hideFullscreen
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
// Don't close when interacting with the PDF viewer toolbar
|
|
||||||
if (isPdf) e.preventDefault();
|
if (isPdf) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar — hidden for PDF (browser's native viewer has its own controls) */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
{!isPdf && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-2 bg-gradient-to-b from-black/70 to-transparent text-white">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className={color}>{icon}</div>
|
{icon}
|
||||||
<span className="text-sm font-medium truncate max-w-[400px]">
|
<span className="text-sm font-medium truncate max-w-[400px]">
|
||||||
{fileName}
|
{resolved.fileName}
|
||||||
</span>
|
</span>
|
||||||
|
{imagePos >= 0 && imageTotal > 1 && (
|
||||||
|
<span className="text-xs text-white/60 ml-1">
|
||||||
|
{imagePos + 1} / {imageTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 pr-16">
|
<div className="flex items-center gap-1 pr-12">
|
||||||
{isImage && blobUrl && (
|
{isImage && blobUrl && (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -141,7 +217,7 @@ export default function AttachmentPreview({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))}
|
onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))}
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" />
|
||||||
@@ -154,7 +230,7 @@ export default function AttachmentPreview({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))}
|
onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))}
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-4 w-4" />
|
<ZoomOut className="h-4 w-4" />
|
||||||
@@ -167,7 +243,7 @@ export default function AttachmentPreview({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={() => setImageZoom(1)}
|
onClick={() => setImageZoom(1)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
@@ -175,7 +251,7 @@ export default function AttachmentPreview({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent>
|
<TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Separator orientation="vertical" className="h-5 mx-1" />
|
<Separator orientation="vertical" className="h-5 mx-1 bg-white/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -183,7 +259,7 @@ export default function AttachmentPreview({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
@@ -193,23 +269,59 @@ export default function AttachmentPreview({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close button — positioned below browser PDF toolbar */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={isPdf
|
||||||
|
? 'absolute top-12 right-4 z-50 h-10 w-10 rounded-full text-white bg-black/50 hover:bg-black/70'
|
||||||
|
: 'absolute top-2 right-4 z-50 h-8 w-8 rounded-full text-white hover:bg-white/20'
|
||||||
|
}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X className={isPdf ? 'h-5 w-5' : 'h-4 w-4'} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Navigation arrows */}
|
||||||
|
{showArrows && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={imagePos <= 0}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 h-10 w-10 rounded-full text-white hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
|
onClick={goPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={imagePos >= imageTotal - 1}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 h-10 w-10 rounded-full text-white hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
|
onClick={goNext}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Preview body */}
|
{/* Preview body */}
|
||||||
<div className="flex-1 min-h-0 bg-muted/30">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
{previewMutation.isPending ? (
|
{previewMutation.isPending ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Skeleton className="w-64 h-4" />
|
<Skeleton className="w-64 h-4 bg-white/10" />
|
||||||
<Skeleton className="w-48 h-4" />
|
<Skeleton className="w-48 h-4 bg-white/10" />
|
||||||
<Skeleton className="w-56 h-4" />
|
<Skeleton className="w-56 h-4 bg-white/10" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : isImage && blobUrl ? (
|
) : isImage && blobUrl ? (
|
||||||
<div className="w-full h-full overflow-auto flex items-center justify-center">
|
<div className="w-full h-full overflow-auto flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={blobUrl}
|
src={blobUrl}
|
||||||
alt={fileName}
|
alt={resolved.fileName}
|
||||||
className="max-w-full"
|
className="max-w-full max-h-full object-contain"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${imageZoom})`,
|
transform: `scale(${imageZoom})`,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
@@ -220,28 +332,26 @@ export default function AttachmentPreview({
|
|||||||
<iframe
|
<iframe
|
||||||
src={blobUrl}
|
src={blobUrl}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
title={fileName}
|
title={resolved.fileName}
|
||||||
/>
|
/>
|
||||||
) : isText && textContent !== null ? (
|
) : isText && textContent !== null ? (
|
||||||
<pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6">
|
<pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6 text-white/90">
|
||||||
{textContent}
|
{textContent}
|
||||||
</pre>
|
</pre>
|
||||||
) : !previewMutation.isPending ? (
|
) : !previewMutation.isPending ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col items-center gap-4 text-white/60">
|
||||||
<div className="flex flex-col items-center gap-4 text-muted-foreground">
|
|
||||||
<FileIcon className="h-16 w-16 opacity-30" />
|
<FileIcon className="h-16 w-16 opacity-30" />
|
||||||
<p className="text-sm">{t('attachment_preview.notAvailable')}</p>
|
<p className="text-sm">{t('attachment_preview.notAvailable')}</p>
|
||||||
<p className="text-xs text-center max-w-md">
|
<p className="text-xs text-center max-w-md">
|
||||||
{t('attachment_preview.notAvailableDesc', {
|
{t('attachment_preview.notAvailableDesc', {
|
||||||
type: contentType || 'unknown',
|
type: resolved.contentType || 'unknown',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
<Button variant="outline" size="sm" onClick={handleDownload} className="text-white border-white/20 hover:bg-white/10">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
{t('attachment.download')}
|
{t('attachment.download')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog';
|
|||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NestedEmailDialog } from './nested-email-dialog';
|
import { NestedEmailDialog } from './nested-email-dialog';
|
||||||
import AttachmentPreview from './attachment-preview';
|
import AttachmentPreview, { type PreviewAttachment } from './attachment-preview';
|
||||||
import { EmailEnvelope } from '@/api';
|
import { EmailEnvelope } from '@/api';
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function MailMessageView({
|
|||||||
const [threadOpen, setThreadOpen] = useState(false);
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
const [blockRemote, setBlockRemote] = useState(true);
|
const [blockRemote, setBlockRemote] = useState(true);
|
||||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
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 = () => {
|
const toggleBlockRemote = () => {
|
||||||
setBlockRemote((prev) => !prev);
|
setBlockRemote((prev) => !prev);
|
||||||
@@ -336,9 +336,12 @@ export function MailMessageView({
|
|||||||
title={attachment.filename}
|
title={attachment.filename}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPreviewAttachment({
|
setPreviewAttachment({
|
||||||
content_hash: attachment.content_hash,
|
attachments: nonInline.map((a) => ({
|
||||||
file_type: attachment.file_type,
|
content_hash: a.content_hash,
|
||||||
filename: attachment.filename,
|
file_type: a.file_type,
|
||||||
|
filename: a.filename,
|
||||||
|
})),
|
||||||
|
index: i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -370,16 +373,6 @@ export function MailMessageView({
|
|||||||
<span className="text-gray-500 text-xs shrink-0">
|
<span className="text-gray-500 text-xs shrink-0">
|
||||||
{formatBytes(attachment.size)}
|
{formatBytes(attachment.size)}
|
||||||
</span>
|
</span>
|
||||||
<Eye
|
|
||||||
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
setPreviewAttachment({
|
|
||||||
content_hash: attachment.content_hash,
|
|
||||||
file_type: attachment.file_type,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{downloadingAttachmentFileName === attachment.filename ? (
|
{downloadingAttachmentFileName === attachment.filename ? (
|
||||||
<Loader className="w-5 h-5 animate-spin" />
|
<Loader className="w-5 h-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -459,15 +452,17 @@ export function MailMessageView({
|
|||||||
fileName={nestedEmlFile?.filename || ''}
|
fileName={nestedEmlFile?.filename || ''}
|
||||||
content_hash={nestedEmlFile?.content_hash}
|
content_hash={nestedEmlFile?.content_hash}
|
||||||
/>
|
/>
|
||||||
{previewAttachment && (
|
{previewAttachment?.attachments?.[previewAttachment.index] && (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
open={!!previewAttachment}
|
open={!!previewAttachment}
|
||||||
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
||||||
accountId={envelope.account_id}
|
accountId={envelope.account_id}
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
contentHash={previewAttachment.content_hash}
|
contentHash={previewAttachment.attachments[previewAttachment.index].content_hash}
|
||||||
contentType={previewAttachment.file_type}
|
contentType={previewAttachment.attachments[previewAttachment.index].file_type}
|
||||||
fileName={previewAttachment.filename}
|
fileName={previewAttachment.attachments[previewAttachment.index].filename}
|
||||||
|
attachments={previewAttachment.attachments}
|
||||||
|
attachmentIndex={previewAttachment.index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog';
|
|||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NestedEmailDialog } from './nested-email-dialog';
|
import { NestedEmailDialog } from './nested-email-dialog';
|
||||||
import AttachmentPreview from '@/features/attachment/attachment-preview';
|
import AttachmentPreview, { type PreviewAttachment } from '@/features/attachment/attachment-preview';
|
||||||
|
|
||||||
|
|
||||||
interface MailMessageViewProps {
|
interface MailMessageViewProps {
|
||||||
@@ -132,7 +132,7 @@ export function MailMessageView({
|
|||||||
const [threadOpen, setThreadOpen] = useState(false);
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
const [blockRemote, setBlockRemote] = useState(true);
|
const [blockRemote, setBlockRemote] = useState(true);
|
||||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
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 = () => {
|
const toggleBlockRemote = () => {
|
||||||
setBlockRemote((prev) => !prev);
|
setBlockRemote((prev) => !prev);
|
||||||
@@ -344,9 +344,12 @@ export function MailMessageView({
|
|||||||
title={attachment.filename}
|
title={attachment.filename}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPreviewAttachment({
|
setPreviewAttachment({
|
||||||
content_hash: attachment.content_hash,
|
attachments: nonInline.map((a) => ({
|
||||||
file_type: attachment.file_type,
|
content_hash: a.content_hash,
|
||||||
filename: attachment.filename,
|
file_type: a.file_type,
|
||||||
|
filename: a.filename,
|
||||||
|
})),
|
||||||
|
index: i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -378,16 +381,6 @@ export function MailMessageView({
|
|||||||
<span className="text-gray-500 text-xs shrink-0">
|
<span className="text-gray-500 text-xs shrink-0">
|
||||||
{formatBytes(attachment.size)}
|
{formatBytes(attachment.size)}
|
||||||
</span>
|
</span>
|
||||||
<Eye
|
|
||||||
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
setPreviewAttachment({
|
|
||||||
content_hash: attachment.content_hash,
|
|
||||||
file_type: attachment.file_type,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{downloadingAttachmentFileName === attachment.filename ? (
|
{downloadingAttachmentFileName === attachment.filename ? (
|
||||||
<Loader className="w-5 h-5 animate-spin" />
|
<Loader className="w-5 h-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -467,15 +460,17 @@ export function MailMessageView({
|
|||||||
fileName={nestedEmlFile?.filename || ''}
|
fileName={nestedEmlFile?.filename || ''}
|
||||||
content_hash={nestedEmlFile?.content_hash}
|
content_hash={nestedEmlFile?.content_hash}
|
||||||
/>
|
/>
|
||||||
{previewAttachment && (
|
{previewAttachment?.attachments?.[previewAttachment.index] && (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
open={!!previewAttachment}
|
open={!!previewAttachment}
|
||||||
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
||||||
accountId={envelope.account_id}
|
accountId={envelope.account_id}
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
contentHash={previewAttachment.content_hash}
|
contentHash={previewAttachment.attachments[previewAttachment.index].content_hash}
|
||||||
contentType={previewAttachment.file_type}
|
contentType={previewAttachment.attachments[previewAttachment.index].file_type}
|
||||||
fileName={previewAttachment.filename}
|
fileName={previewAttachment.attachments[previewAttachment.index].filename}
|
||||||
|
attachments={previewAttachment.attachments}
|
||||||
|
attachmentIndex={previewAttachment.index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user