Files
dashfront/src/components/chat/ChatAttachmentItems.jsx
2026-10-06 12:45:50 +02:00

781 lines
27 KiB
JavaScript

/**
* @file ChatAttachmentItems.jsx
* @description UI components for rendering staged attachments in the input tray,
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` and markdown relative links.
*/
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 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 InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import { enqueueSnackbar } from 'notistack';
import {
SUPPORTED_CATEGORIES,
formatFileSize,
} from './chatAttachments';
const KNOWN_FILE_EXTENSIONS = new Set([
'html', 'htm', 'csv', 'tsv', 'pdf', 'txt', 'text', 'md', 'markdown', 'log',
'json', 'xml', 'yaml', 'yml', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg',
'avif', 'bmp', 'ico', 'zip', 'tar', 'gz', 'bz2', '7z', 'rar',
'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'sql', 'sh', 'py', 'wasm',
'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'odt', 'ods',
]);
/**
* Invalidate/infer a MIME type from a filename or path.
* @param {string} filename
* @returns {string}
*/
export function inferMimeType(filename) {
const ext = (String(filename || '').split('.').pop() || '').toLowerCase();
switch (ext) {
case 'html':
case 'htm':
return 'text/html';
case 'pdf':
return 'application/pdf';
case 'csv':
return 'text/csv';
case 'tsv':
return 'text/tab-separated-values';
case 'xml':
return 'application/xml';
case 'txt':
case 'log':
case 'md':
return 'text/plain';
case 'json':
return 'application/json';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
case 'svg':
return 'image/svg+xml';
case 'gif':
return 'image/gif';
default:
return '';
}
}
/**
* Checks whether a given file can be displayed natively inside a browser tab
* (HTML pages, PDFs, images, text, json, xml, csv, logs, markdown).
* @param {string} filename
* @param {string} [mimeType]
* @returns {boolean}
*/
export function isBrowserViewable(filename, mimeType) {
const type = String(mimeType || inferMimeType(filename) || '').toLowerCase();
const ext = (String(filename || '').split('.').pop() || '').toLowerCase();
if (type.startsWith('image/') || type === 'application/pdf' || type === 'text/html') {
return true;
}
if (['html', 'htm', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) {
return true;
}
if (['txt', 'log', 'json', 'xml', 'md', 'markdown', 'csv', 'tsv', 'js', 'ts', 'jsx', 'tsx', 'css', 'sql', 'sh', 'py'].includes(ext) || type.startsWith('text/')) {
return true;
}
return false;
}
/**
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt` or `dummy-report.html`)
* 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('/api/vfs/')) return p;
if (p.startsWith('/vfs/')) return `/api${p}`;
if (p.startsWith('/')) return p;
const segments = p
.replace(/^\.\//, '')
.split('/')
.filter((s) => s && s !== '.')
.map(encodeURIComponent);
return `/api/vfs/${segments.join('/')}`;
}
/**
* Classifies a markdown link target according to the rule:
* - Internet links are "https://..." or simply "hostname.com" / "hostname.com/filename".
* - A pure relative path with no dotted hostname is always a VFS relative path (e.g. `[download](report.csv)` or `![preview](images/photo.png)`).
* @param {string} rawUrl
* @returns {{ kind: 'web'|'vfs'|'nav'|'none', url?: string, path?: string }}
*/
export function classifyLinkTarget(rawUrl) {
const url = String(rawUrl ?? '').trim();
if (!url) return { kind: 'none' };
if (/^https?:\/\//i.test(url)) {
return { kind: 'web', url };
}
if (/^(mailto:|#)/i.test(url)) {
return { kind: 'nav', url };
}
if (url.startsWith('/')) {
if (url.startsWith('/api/vfs/') || url.startsWith('/vfs/')) {
const vfsPath = url.replace(/^\/api\/vfs\//, '').replace(/^\/vfs\//, '');
return { kind: 'vfs', path: vfsPath };
}
return { kind: 'nav', url };
}
// Strip leading ./
const clean = url.replace(/^\.\//, '');
const firstSlash = clean.indexOf('/');
const firstSegment = firstSlash === -1 ? clean : clean.slice(0, firstSlash);
// If starts with www.
if (/^www\./i.test(firstSegment)) {
return { kind: 'web', url: `https://${clean}` };
}
// If there's a slash and the first segment contains a dot:
// e.g. "hostname.com/filename" or "sub.domain.org/path"
if (firstSlash !== -1 && firstSegment.includes('.')) {
const ext = firstSegment.split('.').pop().toLowerCase();
if (!KNOWN_FILE_EXTENSIONS.has(ext)) {
return { kind: 'web', url: `https://${clean}` };
}
}
// If there's no slash (e.g. "report.csv" or "hostname.com"):
if (firstSlash === -1 && firstSegment.includes('.')) {
const ext = firstSegment.split('.').pop().toLowerCase();
if (KNOWN_FILE_EXTENSIONS.has(ext)) {
// It's a file! e.g. report.csv, dummy-report.html -> VFS relative path
return { kind: 'vfs', path: clean };
}
// Dotted hostname without path, e.g. "example.com"
return { kind: 'web', url: `https://${clean}` };
}
// Pure relative path with no dotted hostname (e.g. "images/photo.png", "report.csv", "attachments/file.txt"):
return { kind: 'vfs', path: clean };
}
/**
* 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=...]` and markdown relative links.
* Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses.
*/
export class VfsAttachmentLink extends PureComponent {
state = {
isDownloading: false,
isOpening: false,
notFound: false,
};
handleOpenInNewTab = async (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
const { name, path } = this.props;
const downloadName = name || (path ? path.split('/').pop() : 'datei');
const { isOpening, isDownloading, notFound } = this.state;
if (isOpening || isDownloading) return;
if (notFound) {
enqueueSnackbar(`Datei "${downloadName}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
return;
}
const vfsUrl = resolveVfsUrl(path);
this.setState({ isOpening: true });
try {
const res = await fetch(vfsUrl, { credentials: 'include' });
if (!res.ok) {
if (res.status === 404) {
this.setState({ notFound: true, isOpening: false });
enqueueSnackbar(`Datei "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
} else {
this.setState({ isOpening: false });
enqueueSnackbar(`Fehler beim Öffnen von "${downloadName}" (${res.status}).`, { variant: 'error' });
}
return;
}
this.setState({ isOpening: false });
window.open(vfsUrl, '_blank', 'noopener,noreferrer');
} catch (err) {
this.setState({ isOpening: false });
enqueueSnackbar(`Netzwerkfehler beim Öffnen: ${err?.message || err}`, { variant: 'error' });
}
};
handleDownload = async (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
const { name, path } = this.props;
const downloadName = name || (path ? path.split('/').pop() : 'datei');
const { isDownloading, isOpening, notFound } = this.state;
if (isDownloading || isOpening) return;
if (notFound) {
enqueueSnackbar(`Datei "${downloadName}" 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 "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
} else {
this.setState({ isDownloading: false });
enqueueSnackbar(`Fehler beim Herunterladen von "${downloadName}" (${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 = downloadName;
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' });
}
};
handleClick = (e) => {
// If modifier keys are pressed (e.g. Ctrl/Cmd/Shift) or middle mouse click, let browser handle natively
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
const { name, path, type } = this.props;
const downloadName = name || (path ? path.split('/').pop() : 'datei');
const effectiveType = type || inferMimeType(downloadName);
const viewable = isBrowserViewable(downloadName, effectiveType);
if (viewable) {
this.handleOpenInNewTab(e);
} else {
this.handleDownload(e);
}
};
handleImageError = () => {
this.setState({ notFound: true });
};
render() {
const { name, path, type, label, children, isUser = false } = this.props;
const { isDownloading, isOpening, notFound } = this.state;
const downloadName = name || (path ? path.split('/').pop() : 'datei');
const displayName = label || children || name || downloadName;
const effectiveType = type || inferMimeType(downloadName);
const vfsUrl = resolveVfsUrl(path);
const canView = isBrowserViewable(downloadName, effectiveType);
const isImage = !notFound && ((effectiveType && effectiveType.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(downloadName || path));
if (isImage && !label && !children) {
return (
<Box
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',
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
transition: 'transform 0.15s, box-shadow 0.15s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0,0,0,0.18)',
},
}}
>
<Box
component="a"
href={notFound ? undefined : vfsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={this.handleClick}
title={notFound ? 'Nicht mehr vorhanden (404)' : `VFS Bild im neuen Tab öffnen: ${downloadName}`}
sx={{
display: 'block',
cursor: notFound ? 'not-allowed' : 'pointer',
textDecoration: 'none',
overflow: 'hidden',
}}
>
<Box
component="img"
src={vfsUrl}
alt={downloadName}
onError={this.handleImageError}
sx={{
width: '100%',
maxHeight: 200,
objectFit: 'cover',
display: 'block',
bgcolor: 'action.hover',
transition: 'transform 0.18s ease-in-out',
'&:hover': notFound ? {} : {
transform: 'scale(1.025)',
},
}}
/>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 0.5,
px: 1,
py: 0.4,
color: isUser ? '#fff' : 'text.primary',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0, flex: 1 }}>
<InsertPhotoRoundedIcon sx={{ fontSize: 16, flexShrink: 0 }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.72rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{downloadName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.2, flexShrink: 0 }}>
{(isDownloading || isOpening) && (
<CircularProgress size={14} color="inherit" thickness={5} sx={{ mr: 0.5 }} />
)}
{!notFound && (
<>
<Tooltip title="In neuem Tab öffnen" arrow>
<IconButton
size="small"
onClick={this.handleOpenInNewTab}
sx={{ p: 0.35, color: 'inherit', opacity: 0.85, '&:hover': { opacity: 1 } }}
>
<OpenInNewRoundedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
<Tooltip title="Herunterladen" arrow>
<IconButton
size="small"
onClick={this.handleDownload}
sx={{ p: 0.35, color: 'inherit', opacity: 0.85, '&:hover': { opacity: 1 } }}
>
<DownloadRoundedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</>
)}
</Box>
</Box>
</Box>
);
}
const titleText = notFound
? `Datei nicht mehr im VFS vorhanden (404): ${downloadName}`
: isOpening
? `Wird im neuen Tab geöffnet…`
: isDownloading
? `Wird heruntergeladen…`
: canView
? `In neuem Tab öffnen (${downloadName})`
: `Herunterladen (${downloadName})`;
return (
<Tooltip title={titleText} arrow>
<Box
component="a"
href={notFound ? undefined : vfsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={this.handleClick}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.85,
my: 0.35,
mx: 0.2,
pl: 1.15,
pr: 0.85,
py: 0.5,
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 || isOpening) ? 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 || isOpening ? (
<CircularProgress size={16} color="inherit" thickness={5} />
) : notFound ? (
<ErrorOutlineRoundedIcon sx={{ fontSize: 20, color: isUser ? 'inherit' : 'error.main' }} />
) : (
getAttachmentIcon(effectiveType, downloadName, 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: 300,
lineHeight: 1.25,
textDecoration: notFound ? 'line-through' : 'none',
}}
>
{displayName}
</Typography>
<Typography
component="span"
sx={{
fontSize: '0.68rem',
opacity: 0.8,
display: 'block',
lineHeight: 1,
}}
>
{notFound
? 'nicht mehr vorhanden (404)'
: (label && label !== downloadName ? `${downloadName} · ${effectiveType || 'VFS'}` : (effectiveType || 'VFS Datei'))}
</Typography>
</Box>
{!notFound && (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.2,
ml: 0.5,
}}
onClick={(e) => e.stopPropagation()}
>
{canView && (
<Tooltip title="In neuem Tab öffnen" arrow>
<IconButton
size="small"
onClick={this.handleOpenInNewTab}
sx={{
p: 0.4,
color: 'inherit',
opacity: 0.75,
'&:hover': { opacity: 1, bgcolor: 'action.hover' },
}}
>
<OpenInNewRoundedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Herunterladen" arrow>
<IconButton
size="small"
onClick={this.handleDownload}
sx={{
p: 0.4,
color: 'inherit',
opacity: 0.75,
'&:hover': { opacity: 1, bgcolor: 'action.hover' },
}}
>
<DownloadRoundedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Tooltip>
);
}
}
/**
* Tray showing files staged for posting above the chat input field.
*/
export class ChatStagedAttachmentsTray extends PureComponent {
render() {
const { attachments, onRemove } = this.props;
if (!attachments || attachments.length === 0) return null;
return (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
p: 1,
mb: 0.5,
borderRadius: 2,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
border: '1px dashed',
borderColor: 'divider',
maxHeight: 140,
overflowY: 'auto',
}}
>
{attachments.map((att, idx) => {
const isImage = att.category === SUPPORTED_CATEGORIES.IMAGE;
const key = att.id || `staged-${idx}`;
if (isImage) {
return (
<Box
key={key}
sx={{
position: 'relative',
width: 54,
height: 54,
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
'&:hover .remove-btn': {
opacity: 1,
},
}}
>
<Box
component="img"
src={att.dataUrl || att.url}
alt={att.name}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
{onRemove && (
<IconButton
size="small"
className="remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemove(att.id || idx);
}}
sx={{
position: 'absolute',
top: 2,
right: 2,
width: 18,
height: 18,
bgcolor: 'rgba(0,0,0,0.65)',
color: '#fff',
opacity: 0.8,
p: 0,
transition: 'opacity 0.15s',
'&:hover': {
bgcolor: 'rgba(0,0,0,0.9)',
},
}}
aria-label="Anhang entfernen"
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Box>
);
}
// Document / file chip
return (
<Box
key={key}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: 2,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
maxWidth: 220,
}}
>
{getAttachmentIcon(att.type, att.name, false)}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.72rem',
lineHeight: 1.2,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={att.name}
>
{att.name}
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: '0.65rem',
lineHeight: 1,
}}
>
{formatFileSize(att.size)}
</Typography>
</Box>
{onRemove && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onRemove(att.id || idx);
}}
sx={{ p: 0.2, ml: 0.25 }}
aria-label="Anhang entfernen"
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
);
})}
</Box>
);
}
}