u
This commit is contained in:
@@ -1,44 +1,342 @@
|
||||
/**
|
||||
* @file ChatAttachmentItems.jsx
|
||||
* @description UI components for rendering staged attachments in the input tray,
|
||||
* and attached files inside message bubbles.
|
||||
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` tags in messages.
|
||||
*/
|
||||
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
||||
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
||||
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
||||
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
||||
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
|
||||
import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
|
||||
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import {
|
||||
SUPPORTED_CATEGORIES,
|
||||
formatFileSize,
|
||||
downloadAttachment,
|
||||
} from './chatAttachments';
|
||||
|
||||
function getCategoryIcon(category, { size = 20, color } = {}) {
|
||||
const sx = { fontSize: size, color };
|
||||
switch (category) {
|
||||
case SUPPORTED_CATEGORIES.PDF:
|
||||
return <PictureAsPdfRoundedIcon sx={{ ...sx, color: color || '#e53935' }} />;
|
||||
case SUPPORTED_CATEGORIES.CSV:
|
||||
return <TableChartRoundedIcon sx={{ ...sx, color: color || '#43a047' }} />;
|
||||
case SUPPORTED_CATEGORIES.XML:
|
||||
case SUPPORTED_CATEGORIES.HTML:
|
||||
return <CodeRoundedIcon sx={{ ...sx, color: color || '#fb8c00' }} />;
|
||||
case SUPPORTED_CATEGORIES.TEXT:
|
||||
return <DescriptionRoundedIcon sx={{ ...sx, color: color || '#0288d1' }} />;
|
||||
default:
|
||||
return <InsertDriveFileRoundedIcon sx={sx} />;
|
||||
/**
|
||||
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt`) to a full browser URL (`/api/vfs/...`).
|
||||
* @param {string} rawPath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveVfsUrl(rawPath) {
|
||||
const p = String(rawPath ?? '').trim();
|
||||
if (!p) return '#';
|
||||
if (/^https?:\/\//i.test(p) || p.startsWith('/')) return p;
|
||||
const segments = p
|
||||
.replace(/^\.\//, '')
|
||||
.split('/')
|
||||
.filter((s) => s && s !== '.')
|
||||
.map(encodeURIComponent);
|
||||
return `/api/vfs/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse attributes from an attachment tag:
|
||||
* `name="Rechnung 89589.txt" path="attachments/Rechnung 89589.txt" type=text/plain`
|
||||
* @param {string} attrString
|
||||
* @returns {{ name: string, path: string, type: string }}
|
||||
*/
|
||||
export function parseAttachmentTag(attrString) {
|
||||
const attrs = {};
|
||||
const regex = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/g;
|
||||
let m;
|
||||
while ((m = regex.exec(attrString)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
const val = m[2] ?? m[3] ?? m[4] ?? '';
|
||||
attrs[key] = val;
|
||||
}
|
||||
return {
|
||||
name: attrs.name || (attrs.path ? attrs.path.split('/').pop() : 'Datei'),
|
||||
path: attrs.path || '',
|
||||
type: attrs.type || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
|
||||
* @param {string} rawText
|
||||
* @returns {{ attachments: Array<{ name: string, path: string, type: string, url: string }>, cleanText: string }}
|
||||
*/
|
||||
export function parseAttachmentsFromText(rawText) {
|
||||
if (typeof rawText !== 'string' || !rawText.includes('[ATTACHMENT')) {
|
||||
return { attachments: [], cleanText: rawText || '' };
|
||||
}
|
||||
|
||||
const attachments = [];
|
||||
const tagRegex = /\[ATTACHMENT\s+([^\]]+)\]/g;
|
||||
let match;
|
||||
while ((match = tagRegex.exec(rawText)) !== null) {
|
||||
const parsed = parseAttachmentTag(match[1]);
|
||||
if (parsed.name || parsed.path) {
|
||||
attachments.push({
|
||||
...parsed,
|
||||
url: resolveVfsUrl(parsed.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cleanText = rawText.replace(tagRegex, '').trim();
|
||||
return { attachments, cleanText };
|
||||
}
|
||||
|
||||
function getAttachmentIcon(type, name, isUser) {
|
||||
const t = (type || '').toLowerCase();
|
||||
const n = (name || '').toLowerCase();
|
||||
const sx = { fontSize: 20, color: isUser ? 'inherit' : undefined, flexShrink: 0 };
|
||||
|
||||
if (t === 'application/pdf' || n.endsWith('.pdf')) {
|
||||
return <PictureAsPdfRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#e53935' }} />;
|
||||
}
|
||||
if (t.includes('csv') || n.endsWith('.csv')) {
|
||||
return <TableChartRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#43a047' }} />;
|
||||
}
|
||||
if (t.includes('xml') || t.includes('html') || n.endsWith('.xml') || n.endsWith('.html') || n.endsWith('.htm')) {
|
||||
return <CodeRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#fb8c00' }} />;
|
||||
}
|
||||
if (t.startsWith('text/') || n.endsWith('.txt') || n.endsWith('.md') || n.endsWith('.log')) {
|
||||
return <DescriptionRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#0288d1' }} />;
|
||||
}
|
||||
if (t.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(n)) {
|
||||
return <InsertPhotoRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#8e24aa' }} />;
|
||||
}
|
||||
return <InsertDriveFileRoundedIcon sx={sx} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]`.
|
||||
* Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses.
|
||||
*/
|
||||
export class VfsAttachmentLink extends PureComponent {
|
||||
state = {
|
||||
isDownloading: false,
|
||||
notFound: false,
|
||||
};
|
||||
|
||||
handleClick = async (e) => {
|
||||
// If modifier keys are pressed (e.g. open in new tab with Ctrl/Cmd), let browser handle
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
|
||||
const { name, path } = this.props;
|
||||
const { isDownloading, notFound } = this.state;
|
||||
if (isDownloading) return;
|
||||
|
||||
if (notFound) {
|
||||
enqueueSnackbar(`Datei "${name}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const vfsUrl = resolveVfsUrl(path);
|
||||
this.setState({ isDownloading: true });
|
||||
|
||||
try {
|
||||
const res = await fetch(vfsUrl, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
this.setState({ notFound: true, isDownloading: false });
|
||||
enqueueSnackbar(`Datei "${name}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
||||
} else {
|
||||
this.setState({ isDownloading: false });
|
||||
enqueueSnackbar(`Fehler beim Herunterladen von "${name}" (${res.status}).`, { variant: 'error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const tempLink = document.createElement('a');
|
||||
tempLink.href = objectUrl;
|
||||
tempLink.download = name || 'datei';
|
||||
document.body.appendChild(tempLink);
|
||||
tempLink.click();
|
||||
document.body.removeChild(tempLink);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
this.setState({ isDownloading: false });
|
||||
} catch (err) {
|
||||
this.setState({ isDownloading: false });
|
||||
enqueueSnackbar(`Netzwerkfehler beim Herunterladen: ${err?.message || err}`, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
handleImageError = () => {
|
||||
this.setState({ notFound: true });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, path, type, isUser = false } = this.props;
|
||||
const { isDownloading, notFound } = this.state;
|
||||
const vfsUrl = resolveVfsUrl(path);
|
||||
const isImage = !notFound && ((type && type.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(name || path));
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Box
|
||||
component="a"
|
||||
href={vfsUrl}
|
||||
onClick={this.handleClick}
|
||||
title={isDownloading ? 'Wird heruntergeladen…' : `VFS Bild öffnen / herunterladen: ${name}`}
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
my: 0.6,
|
||||
maxWidth: 280,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: isDownloading ? 0.75 : 1,
|
||||
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={vfsUrl}
|
||||
alt={name}
|
||||
onError={this.handleImageError}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
|
||||
color: isUser ? '#fff' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
<InsertPhotoRoundedIcon sx={{ fontSize: 16 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.72rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isDownloading && <CircularProgress size={12} color="inherit" thickness={5} />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const titleText = notFound
|
||||
? `Datei nicht mehr im VFS vorhanden (404): ${name}`
|
||||
: isDownloading
|
||||
? `Wird heruntergeladen…`
|
||||
: `VFS Datei herunterladen / öffnen: ${name}`;
|
||||
|
||||
return (
|
||||
<Tooltip title={titleText} arrow>
|
||||
<Box
|
||||
component="a"
|
||||
href={notFound ? undefined : vfsUrl}
|
||||
onClick={this.handleClick}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
my: 0.35,
|
||||
mx: 0.2,
|
||||
px: 1.25,
|
||||
py: 0.65,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: notFound
|
||||
? (isUser ? 'rgba(255,255,255,0.2)' : 'error.light')
|
||||
: (isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider'),
|
||||
bgcolor: notFound
|
||||
? (isUser ? 'rgba(0,0,0,0.2)' : 'action.disabledBackground')
|
||||
: (isUser
|
||||
? 'rgba(255, 255, 255, 0.18)'
|
||||
: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)')),
|
||||
textDecoration: notFound ? 'line-through' : 'none',
|
||||
color: notFound
|
||||
? (isUser ? 'rgba(255,255,255,0.6)' : 'text.disabled')
|
||||
: (isUser ? 'inherit' : 'primary.main'),
|
||||
opacity: notFound ? 0.7 : (isDownloading ? 0.75 : 1),
|
||||
cursor: notFound ? 'not-allowed' : 'pointer',
|
||||
verticalAlign: 'middle',
|
||||
maxWidth: '100%',
|
||||
transition: 'all 150ms ease-in-out',
|
||||
'&:hover': notFound ? {} : {
|
||||
bgcolor: isUser ? 'rgba(255, 255, 255, 0.28)' : 'action.hover',
|
||||
borderColor: isUser ? '#fff' : 'primary.main',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<CircularProgress size={16} color="inherit" thickness={5} />
|
||||
) : notFound ? (
|
||||
<ErrorOutlineRoundedIcon sx={{ fontSize: 20, color: isUser ? 'inherit' : 'error.main' }} />
|
||||
) : (
|
||||
getAttachmentIcon(type, name, isUser)
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 260,
|
||||
lineHeight: 1.25,
|
||||
textDecoration: notFound ? 'line-through' : 'none',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
opacity: 0.8,
|
||||
display: 'block',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +345,7 @@ function getCategoryIcon(category, { size = 20, color } = {}) {
|
||||
*/
|
||||
export class ChatStagedAttachmentsTray extends PureComponent {
|
||||
render() {
|
||||
const { attachments, onRemove, onPreview } = this.props;
|
||||
const { attachments, onRemove } = this.props;
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -82,13 +380,11 @@ export class ChatStagedAttachmentsTray extends PureComponent {
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: onPreview ? 'pointer' : 'default',
|
||||
flexShrink: 0,
|
||||
'&:hover .remove-btn': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
onClick={() => onPreview?.(att)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
@@ -148,15 +444,9 @@ export class ChatStagedAttachmentsTray extends PureComponent {
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxWidth: 220,
|
||||
cursor: onPreview ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={() => onPreview?.(att)}
|
||||
>
|
||||
{getCategoryIcon(att.category, { size: 18 })}
|
||||
{getAttachmentIcon(att.type, att.name, false)}
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
@@ -205,167 +495,3 @@ export class ChatStagedAttachmentsTray extends PureComponent {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders attachments inside a posted message bubble.
|
||||
*/
|
||||
export class ChatMessageAttachments extends PureComponent {
|
||||
render() {
|
||||
const { attachments, isUser, onPreview } = this.props;
|
||||
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const images = attachments.filter((a) => a.category === SUPPORTED_CATEGORIES.IMAGE);
|
||||
const documents = attachments.filter((a) => a.category !== SUPPORTED_CATEGORIES.IMAGE);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, my: 0.5, maxWidth: '100%' }}>
|
||||
{/* Images Grid */}
|
||||
{images.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: images.length === 1 ? '1fr' : 'repeat(auto-fit, minmax(110px, 1fr))',
|
||||
gap: 0.75,
|
||||
maxWidth: 380,
|
||||
}}
|
||||
>
|
||||
{images.map((img, idx) => {
|
||||
const src = img.dataUrl || img.url;
|
||||
return (
|
||||
<Box
|
||||
key={img.id || `img-${idx}`}
|
||||
onClick={() => onPreview?.(img)}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
maxHeight: images.length === 1 ? 260 : 130,
|
||||
border: '1px solid',
|
||||
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
|
||||
bgcolor: 'action.hover',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={img.name || 'Bildanhang'}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: images.length === 1 ? 260 : 130,
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Documents Cards */}
|
||||
{documents.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{documents.map((doc, idx) => {
|
||||
return (
|
||||
<Box
|
||||
key={doc.id || `doc-${idx}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.2,
|
||||
px: 1.25,
|
||||
py: 0.85,
|
||||
borderRadius: 2,
|
||||
bgcolor: isUser
|
||||
? 'rgba(255, 255, 255, 0.16)'
|
||||
: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'),
|
||||
border: '1px solid',
|
||||
borderColor: isUser ? 'rgba(255, 255, 255, 0.25)' : 'divider',
|
||||
color: isUser ? 'inherit' : 'text.primary',
|
||||
maxWidth: 340,
|
||||
transition: 'background-color 0.15s',
|
||||
'&:hover': {
|
||||
bgcolor: isUser
|
||||
? 'rgba(255, 255, 255, 0.24)'
|
||||
: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{getCategoryIcon(doc.category, {
|
||||
size: 24,
|
||||
color: isUser ? 'inherit' : undefined,
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: 1.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={doc.name}
|
||||
>
|
||||
{doc.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{formatFileSize(doc.size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Tooltip title="Vorschau / Öffnen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPreview?.(doc);
|
||||
}}
|
||||
sx={{ color: 'inherit', p: 0.5 }}
|
||||
>
|
||||
<VisibilityRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Herunterladen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadAttachment(doc);
|
||||
}}
|
||||
sx={{ color: 'inherit', p: 0.5 }}
|
||||
>
|
||||
<DownloadRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user