mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add in-browser attachment preview for images, PDFs, and text files #303
This commit is contained in:
@@ -41,6 +41,18 @@ export const download_attachment = async (accountId: number, id: string, content
|
||||
saveAs(blob, fileName);
|
||||
};
|
||||
|
||||
/** Fetch raw attachment content for in-browser preview (Content-Disposition: inline). */
|
||||
export const preview_attachment = async (accountId: number, id: string, content_hash: string) => {
|
||||
const response = await axiosInstance.get(
|
||||
`api/v1/preview-attachment/${accountId}/${id}`,
|
||||
{
|
||||
params: { content_hash },
|
||||
responseType: 'blob',
|
||||
}
|
||||
);
|
||||
return response.data as Blob;
|
||||
};
|
||||
|
||||
export const download_nested_attachment = async (accountId: number, id: string, content_hash: string, nested_content_hash: string, fileName: string) => {
|
||||
const response = await axiosInstance.get(`api/v1/download-nested-attachment/${accountId}/${id}?content_hash=${content_hash}&nested_content_hash=${nested_content_hash}`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data]);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Download, FileIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { preview_attachment, download_attachment } from '@/api/mailbox/envelope/api';
|
||||
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))$/;
|
||||
|
||||
interface AttachmentPreviewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
accountId: number;
|
||||
envelopeId: string;
|
||||
contentHash: string;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
function isImagePreview(contentType: string) {
|
||||
return PREVIEWABLE_IMAGE.test(contentType);
|
||||
}
|
||||
|
||||
function isPdfPreview(contentType: string) {
|
||||
return contentType === 'application/pdf';
|
||||
}
|
||||
|
||||
function isTextPreview(contentType: string) {
|
||||
return PREVIEWABLE_TEXT.test(contentType);
|
||||
}
|
||||
|
||||
export default function AttachmentPreview({
|
||||
open,
|
||||
onOpenChange,
|
||||
accountId,
|
||||
envelopeId,
|
||||
contentHash,
|
||||
contentType,
|
||||
fileName,
|
||||
}: AttachmentPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [textContent, setTextContent] = useState<string | null>(null);
|
||||
const [imageZoom, setImageZoom] = useState(1);
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => preview_attachment(accountId, envelopeId, contentHash),
|
||||
onSuccess: (blob) => {
|
||||
if (isTextPreview(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 });
|
||||
setBlobUrl(URL.createObjectURL(typedBlob));
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('attachment_preview.failedToLoad'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setBlobUrl(null);
|
||||
setTextContent(null);
|
||||
setImageZoom(1);
|
||||
previewMutation.mutate();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
download_attachment(accountId, envelopeId, contentHash, fileName);
|
||||
};
|
||||
|
||||
const { icon, color } = useMemo(() => getFileConfig(contentType), [contentType]);
|
||||
|
||||
const isImage = isImagePreview(contentType);
|
||||
const isPdf = isPdfPreview(contentType);
|
||||
const isText = isTextPreview(contentType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[calc(100vw-2rem)] max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
onInteractOutside={(e) => {
|
||||
// Don't close when interacting with the PDF viewer toolbar
|
||||
if (isPdf) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={color}>{icon}</div>
|
||||
<span className="text-sm font-medium truncate max-w-[400px]">
|
||||
{fileName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-16">
|
||||
{isImage && blobUrl && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attachment_preview.zoomIn')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attachment_preview.zoomOut')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setImageZoom(1)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5 mx-1" />
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attachment.download')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview body */}
|
||||
<div className="flex-1 min-h-0 bg-muted/30">
|
||||
{previewMutation.isPending ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Skeleton className="w-64 h-4" />
|
||||
<Skeleton className="w-48 h-4" />
|
||||
<Skeleton className="w-56 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
) : isImage && blobUrl ? (
|
||||
<div className="w-full h-full overflow-auto flex items-center justify-center">
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full"
|
||||
style={{
|
||||
transform: `scale(${imageZoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isPdf && blobUrl ? (
|
||||
<iframe
|
||||
src={blobUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={fileName}
|
||||
/>
|
||||
) : isText && textContent !== null ? (
|
||||
<pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6">
|
||||
{textContent}
|
||||
</pre>
|
||||
) : !previewMutation.isPending ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-4 text-muted-foreground">
|
||||
<FileIcon className="h-16 w-16 opacity-30" />
|
||||
<p className="text-sm">{t('attachment_preview.notAvailable')}</p>
|
||||
<p className="text-xs text-center max-w-md">
|
||||
{t('attachment_preview.notAvailableDesc', {
|
||||
type: contentType || 'unknown',
|
||||
})}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('attachment.download')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -40,6 +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 { EmailEnvelope } from '@/api';
|
||||
|
||||
|
||||
@@ -123,6 +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 toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
@@ -328,12 +330,20 @@ export function MailMessageView({
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-xs font-medium text-foreground/90 cursor-pointer hover:text-primary hover:underline transition-colors text-left"
|
||||
title={attachment.filename}
|
||||
onClick={() =>
|
||||
setPreviewAttachment({
|
||||
content_hash: attachment.content_hash,
|
||||
file_type: attachment.file_type,
|
||||
filename: attachment.filename,
|
||||
})
|
||||
}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
</button>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase">
|
||||
{attachment.file_type.split('/').pop()}
|
||||
</span>
|
||||
@@ -360,11 +370,21 @@ export function MailMessageView({
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
{formatBytes(attachment.size)}
|
||||
</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 ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Download
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
className="w-5 h-5 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDownloadingAttachmentFileName(attachment.filename);
|
||||
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
|
||||
@@ -439,6 +459,17 @@ export function MailMessageView({
|
||||
fileName={nestedEmlFile?.filename || ''}
|
||||
content_hash={nestedEmlFile?.content_hash}
|
||||
/>
|
||||
{previewAttachment && (
|
||||
<AttachmentPreview
|
||||
open={!!previewAttachment}
|
||||
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
||||
accountId={envelope.account_id}
|
||||
envelopeId={envelope.id}
|
||||
contentHash={previewAttachment.content_hash}
|
||||
contentType={previewAttachment.file_type}
|
||||
fileName={previewAttachment.filename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
|
||||
import { Row } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,12 +29,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Copy, Download, MoreVertical } from 'lucide-react'
|
||||
import { Copy, Download, Eye, MoreVertical } from 'lucide-react'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
import { useSearchAttachments } from '@/hooks/use-search-attachments'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { download_attachment } from '@/api/mailbox/envelope/api'
|
||||
import AttachmentPreview from '@/features/attachment/attachment-preview'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AttachmentModel>
|
||||
@@ -43,6 +45,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setFilter } = useSearchAttachments();
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (content_hash: string) =>
|
||||
@@ -87,6 +90,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('attachment.preview')}
|
||||
<DropdownMenuShortcut>
|
||||
<Eye size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
disabled={downloadMutation.isPending}
|
||||
@@ -105,6 +120,15 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AttachmentPreview
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
accountId={row.original.account_id}
|
||||
envelopeId={row.original.envelope_id}
|
||||
contentHash={row.original.content_hash}
|
||||
contentType={row.original.content_type}
|
||||
fileName={row.original.name ?? row.original.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -40,6 +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';
|
||||
|
||||
|
||||
interface MailMessageViewProps {
|
||||
@@ -131,6 +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 toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
@@ -336,12 +338,20 @@ export function MailMessageView({
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-xs font-medium text-foreground/90 cursor-pointer hover:text-primary hover:underline transition-colors text-left"
|
||||
title={attachment.filename}
|
||||
onClick={() =>
|
||||
setPreviewAttachment({
|
||||
content_hash: attachment.content_hash,
|
||||
file_type: attachment.file_type,
|
||||
filename: attachment.filename,
|
||||
})
|
||||
}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
</button>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase">
|
||||
{attachment.file_type.split('/').pop()}
|
||||
</span>
|
||||
@@ -368,11 +378,21 @@ export function MailMessageView({
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
{formatBytes(attachment.size)}
|
||||
</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 ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Download
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
className="w-5 h-5 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDownloadingAttachmentFileName(attachment.filename);
|
||||
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
|
||||
@@ -447,6 +467,17 @@ export function MailMessageView({
|
||||
fileName={nestedEmlFile?.filename || ''}
|
||||
content_hash={nestedEmlFile?.content_hash}
|
||||
/>
|
||||
{previewAttachment && (
|
||||
<AttachmentPreview
|
||||
open={!!previewAttachment}
|
||||
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
||||
accountId={envelope.account_id}
|
||||
envelopeId={envelope.id}
|
||||
contentHash={previewAttachment.content_hash}
|
||||
contentType={previewAttachment.file_type}
|
||||
fileName={previewAttachment.filename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "الموضوع",
|
||||
"viewEmbeddedEmail": "عرض البريد الإلكتروني المضمن"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "فشل تحميل المعاينة",
|
||||
"notAvailable": "المعاينة غير متوفرة",
|
||||
"notAvailableDesc": "لا يمكن معاينة هذا النوع من الملفات.",
|
||||
"resetZoom": "إعادة تعيين التكبير",
|
||||
"zoomIn": "تكبير",
|
||||
"zoomOut": "تصغير"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
|
||||
"invalidPassword": "كلمة مرور غير صالحة. الرجاء المحاولة مرة أخرى.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Emne",
|
||||
"viewEmbeddedEmail": "Vis indlejret e-mail"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Kunne ikke indlæse forhåndsvisning",
|
||||
"notAvailable": "Forhåndsvisning ikke tilgængelig",
|
||||
"notAvailableDesc": "Denne filtype kan ikke forhåndsvises.",
|
||||
"resetZoom": "Nulstil zoom",
|
||||
"zoomIn": "Zoom ind",
|
||||
"zoomOut": "Zoom ud"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Er du sikker på, du vil logge ud?",
|
||||
"invalidPassword": "Ugyldig adgangskode. Prøv venligst igen.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Betreff",
|
||||
"viewEmbeddedEmail": "Eingebettete E-Mail anzeigen"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Vorschau konnte nicht geladen werden",
|
||||
"notAvailable": "Vorschau nicht verfügbar",
|
||||
"notAvailableDesc": "Vorschau für diesen Dateityp nicht möglich.",
|
||||
"resetZoom": "Zoom zurücksetzen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Sind Sie sicher, dass Sie sich abmelden möchten?",
|
||||
"invalidPassword": "Ungültiges Passwort. Bitte versuchen Sie es erneut.",
|
||||
|
||||
@@ -404,6 +404,7 @@
|
||||
"downloading": "Downloading...",
|
||||
"emailMessageNotFound": "Unable to find the original email. It may have been deleted.",
|
||||
"name": "Filename",
|
||||
"preview": "Preview",
|
||||
"search_input_placeholder": "Search attachments (use \" \" for phrase search)",
|
||||
"sender": "Sender",
|
||||
"sender_with_count": "Sender ({{count}})",
|
||||
@@ -413,6 +414,14 @@
|
||||
"subject": "Subject",
|
||||
"viewEmbeddedEmail": "View embedded email"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Failed to load preview",
|
||||
"notAvailable": "Preview not available",
|
||||
"notAvailableDesc": "Preview is not supported for this file type.",
|
||||
"resetZoom": "Reset zoom",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Are you sure you want to log out?",
|
||||
"invalidPassword": "Invalid password. Please try again.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Asunto",
|
||||
"viewEmbeddedEmail": "Ver correo electrónico incrustado"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Error al cargar la vista previa",
|
||||
"notAvailable": "Vista previa no disponible",
|
||||
"notAvailableDesc": "No se puede previsualizar este tipo de archivo.",
|
||||
"resetZoom": "Restablecer zoom",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "¿Estás seguro de que quieres cerrar sesión?",
|
||||
"invalidPassword": "Contraseña inválida. Inténtalo de nuevo.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Aihe",
|
||||
"viewEmbeddedEmail": "Näytä upotettu sähköposti"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Esikatselun lataaminen epäonnistui",
|
||||
"notAvailable": "Esikatselua ei saatavilla",
|
||||
"notAvailableDesc": "Tätä tiedostotyyppiä ei voi esikatsella.",
|
||||
"resetZoom": "Nollaa zoomaus",
|
||||
"zoomIn": "Lähennä",
|
||||
"zoomOut": "Loitonna"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Oletko varma, että haluat kirjautua ulos?",
|
||||
"invalidPassword": "Virheellinen salasana. Yritä uudelleen.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Objet",
|
||||
"viewEmbeddedEmail": "Voir l'e-mail intégré"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Échec du chargement de l'aperçu",
|
||||
"notAvailable": "Aperçu non disponible",
|
||||
"notAvailableDesc": "Aperçu non disponible pour ce type de fichier.",
|
||||
"resetZoom": "Réinitialiser le zoom",
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"invalidPassword": "Mot de passe non valide. Veuillez réessayer.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Oggetto",
|
||||
"viewEmbeddedEmail": "Visualizza email incorporata"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Impossibile caricare l'anteprima",
|
||||
"notAvailable": "Anteprima non disponibile",
|
||||
"notAvailableDesc": "Anteprima non supportata per questo tipo di file.",
|
||||
"resetZoom": "Ripristina zoom",
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Rimpicciolisci"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Sei sicuro di voler uscire?",
|
||||
"invalidPassword": "Password non valida. Riprova.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "件名",
|
||||
"viewEmbeddedEmail": "埋め込みメールを表示"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "プレビューの読み込みに失敗しました",
|
||||
"notAvailable": "プレビューは利用できません",
|
||||
"notAvailableDesc": "このファイル形式はプレビューできません。",
|
||||
"resetZoom": "ズームをリセット",
|
||||
"zoomIn": "拡大",
|
||||
"zoomOut": "縮小"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "ログアウトしてもよろしいですか?",
|
||||
"invalidPassword": "パスワードが無効です。もう一度お試しください。",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "제목",
|
||||
"viewEmbeddedEmail": "포함된 메일 보기"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "미리보기를 불러오지 못했습니다",
|
||||
"notAvailable": "미리보기를 사용할 수 없습니다",
|
||||
"notAvailableDesc": "이 파일 형식은 미리볼 수 없습니다.",
|
||||
"resetZoom": "확대/축소 초기화",
|
||||
"zoomIn": "확대",
|
||||
"zoomOut": "축소"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "정말로 로그아웃하시겠습니까?",
|
||||
"invalidPassword": "비밀번호가 유효하지 않습니다. 다시 시도해 주세요.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Onderwerp",
|
||||
"viewEmbeddedEmail": "Ingesloten e-mail bekijken"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Voorpreview laden mislukt",
|
||||
"notAvailable": "Voorpreview niet beschikbaar",
|
||||
"notAvailableDesc": "Voorpreview niet ondersteund voor dit bestandstype.",
|
||||
"resetZoom": "Zoom herstellen",
|
||||
"zoomIn": "Inzoomen",
|
||||
"zoomOut": "Uitzoomen"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Weet u zeker dat u wilt uitloggen?",
|
||||
"invalidPassword": "Ongeldig wachtwoord. Probeer het opnieuw.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Emne",
|
||||
"viewEmbeddedEmail": "Vis innebygd e-post"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Kunne ikke laste forhåndsvisning",
|
||||
"notAvailable": "Forhåndsvisning ikke tilgjengelig",
|
||||
"notAvailableDesc": "Denne filtypen kan ikke forhåndsvises.",
|
||||
"resetZoom": "Nullstill zoom",
|
||||
"zoomIn": "Zoom inn",
|
||||
"zoomOut": "Zoom ut"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Er du sikker på at du vil logge ut?",
|
||||
"invalidPassword": "Ugyldig passord. Vennligst prøv igjen.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Temat",
|
||||
"viewEmbeddedEmail": "Wyświetl osadzony e-mail"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Nie udało się załadować podglądu",
|
||||
"notAvailable": "Podgląd niedostępny",
|
||||
"notAvailableDesc": "Podgląd tego typu pliku nie jest obsługiwany.",
|
||||
"resetZoom": "Resetuj powiększenie",
|
||||
"zoomIn": "Powiększ",
|
||||
"zoomOut": "Pomniejsz"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Czy na pewno chcesz się wylogować?",
|
||||
"invalidPassword": "Nieprawidłowe hasło, spróbuj ponownie",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Assunto",
|
||||
"viewEmbeddedEmail": "Ver e-mail incorporado"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Falha ao carregar a visualização",
|
||||
"notAvailable": "Visualização não disponível",
|
||||
"notAvailableDesc": "Não é possível visualizar este tipo de arquivo.",
|
||||
"resetZoom": "Redefinir zoom",
|
||||
"zoomIn": "Mais zoom",
|
||||
"zoomOut": "Menos zoom"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Tem certeza que deseja sair?",
|
||||
"invalidPassword": "Senha inválida, por favor, tente novamente.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Тема",
|
||||
"viewEmbeddedEmail": "Просмотреть вложенное письмо"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Не удалось загрузить предпросмотр",
|
||||
"notAvailable": "Предпросмотр недоступен",
|
||||
"notAvailableDesc": "Предпросмотр для этого типа файла не поддерживается.",
|
||||
"resetZoom": "Сбросить масштаб",
|
||||
"zoomIn": "Увеличить масштаб",
|
||||
"zoomOut": "Уменьшить масштаб"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Вы уверены, что хотите выйти?",
|
||||
"invalidPassword": "Неверный пароль. Пожалуйста, попробуйте снова.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "Ämne",
|
||||
"viewEmbeddedEmail": "Visa inbäddat e-postmeddelande"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "Misslyckades att ladda förhandsgranskning",
|
||||
"notAvailable": "Förhandsgranskning inte tillgänglig",
|
||||
"notAvailableDesc": "Det går inte att förhandsgranska den här filtypen.",
|
||||
"resetZoom": "Återställ zoom",
|
||||
"zoomIn": "Zooma in",
|
||||
"zoomOut": "Zooma ut"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Är du säker på att du vill logga ut?",
|
||||
"invalidPassword": "Ogiltigt lösenord. Var god försök igen.",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "主旨",
|
||||
"viewEmbeddedEmail": "檢視內嵌郵件"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "無法載入預覽",
|
||||
"notAvailable": "無法預覽",
|
||||
"notAvailableDesc": "此檔案類型不支援預覽。",
|
||||
"resetZoom": "重設縮放",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "縮小"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "確定要登出嗎?",
|
||||
"invalidPassword": "密碼無效,請再試一次。",
|
||||
|
||||
@@ -413,6 +413,14 @@
|
||||
"subject": "主题",
|
||||
"viewEmbeddedEmail": "查看内嵌邮件"
|
||||
},
|
||||
"attachment_preview": {
|
||||
"failedToLoad": "无法加载预览",
|
||||
"notAvailable": "无法预览",
|
||||
"notAvailableDesc": "此文件类型不支持预览。",
|
||||
"resetZoom": "重置缩放",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "您确定要退出登录吗?",
|
||||
"invalidPassword": "密码无效,请重试。",
|
||||
|
||||
Reference in New Issue
Block a user