u
This commit is contained in:
371
src/components/chat/ChatAttachmentItems.jsx
Normal file
371
src/components/chat/ChatAttachmentItems.jsx
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @file ChatAttachmentItems.jsx
|
||||
* @description UI components for rendering staged attachments in the input tray,
|
||||
* and attached files inside message bubbles.
|
||||
*/
|
||||
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
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 {
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tray showing files staged for posting above the chat input field.
|
||||
*/
|
||||
export class ChatStagedAttachmentsTray extends PureComponent {
|
||||
render() {
|
||||
const { attachments, onRemove, onPreview } = 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',
|
||||
cursor: onPreview ? 'pointer' : 'default',
|
||||
flexShrink: 0,
|
||||
'&:hover .remove-btn': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
onClick={() => onPreview?.(att)}
|
||||
>
|
||||
<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,
|
||||
cursor: onPreview ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={() => onPreview?.(att)}
|
||||
>
|
||||
{getCategoryIcon(att.category, { size: 18 })}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
282
src/components/chat/ChatAttachmentPreviewModal.jsx
Normal file
282
src/components/chat/ChatAttachmentPreviewModal.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @file ChatAttachmentPreviewModal.jsx
|
||||
* @description Modal dialog for inspecting chat attachments (Images, PDFs, CSV, Text, XML, HTML).
|
||||
*/
|
||||
|
||||
import React, { PureComponent } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
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 {
|
||||
SUPPORTED_CATEGORIES,
|
||||
formatFileSize,
|
||||
downloadAttachment,
|
||||
} from './chatAttachments';
|
||||
|
||||
export default class ChatAttachmentPreviewModal extends PureComponent {
|
||||
state = {
|
||||
copied: false,
|
||||
};
|
||||
|
||||
handleCopyText = () => {
|
||||
const { attachment } = this.props;
|
||||
const text = attachment?.textContent || attachment?.previewSnippet || '';
|
||||
if (!text) return;
|
||||
navigator.clipboard?.writeText(text).then(() => {
|
||||
this.setState({ copied: true });
|
||||
setTimeout(() => this.setState({ copied: false }), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
handleDownload = () => {
|
||||
const { attachment } = this.props;
|
||||
if (attachment) {
|
||||
downloadAttachment(attachment);
|
||||
}
|
||||
};
|
||||
|
||||
renderIcon(category) {
|
||||
switch (category) {
|
||||
case SUPPORTED_CATEGORIES.IMAGE:
|
||||
return null;
|
||||
case SUPPORTED_CATEGORIES.PDF:
|
||||
return <PictureAsPdfRoundedIcon sx={{ color: '#e53935' }} />;
|
||||
case SUPPORTED_CATEGORIES.CSV:
|
||||
return <TableChartRoundedIcon sx={{ color: '#43a047' }} />;
|
||||
case SUPPORTED_CATEGORIES.XML:
|
||||
case SUPPORTED_CATEGORIES.HTML:
|
||||
return <CodeRoundedIcon sx={{ color: '#fb8c00' }} />;
|
||||
case SUPPORTED_CATEGORIES.TEXT:
|
||||
return <DescriptionRoundedIcon sx={{ color: '#0288d1' }} />;
|
||||
default:
|
||||
return <InsertDriveFileRoundedIcon sx={{ color: 'text.secondary' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { attachment } = this.props;
|
||||
if (!attachment) return null;
|
||||
|
||||
const { category, dataUrl, url, textContent, previewSnippet, name } = attachment;
|
||||
const sourceUrl = dataUrl || url;
|
||||
const effectiveText = textContent || previewSnippet;
|
||||
|
||||
if (category === SUPPORTED_CATEGORIES.IMAGE) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.03)'),
|
||||
borderRadius: 2,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={sourceUrl}
|
||||
alt={name || 'Anhang'}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 1.5,
|
||||
boxShadow: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 4px 20px rgba(0,0,0,0.7)'
|
||||
: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (category === SUPPORTED_CATEGORIES.PDF) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '70vh', minHeight: 400 }}>
|
||||
<Box
|
||||
component="iframe"
|
||||
src={sourceUrl}
|
||||
title={name || 'PDF Vorschau'}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveText !== undefined) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '65vh',
|
||||
overflow: 'auto',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.5)' : '#f8f9fa'),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{effectiveText}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Direktvorschau verfügbar. Sie können die Datei herunterladen.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, onClose, attachment } = this.props;
|
||||
const { copied } = this.state;
|
||||
if (!attachment) return null;
|
||||
|
||||
const { name, size, category } = attachment;
|
||||
const hasText = Boolean(attachment.textContent || attachment.previewSnippet);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
sx={{
|
||||
zIndex: 14000,
|
||||
'& .MuiDialog-paper': {
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
py: 1.5,
|
||||
px: 2.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }}>
|
||||
{this.renderIcon(category)}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name || 'Anhang'}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={formatFileSize(size)}
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<IconButton size="small" onClick={onClose} aria-label="Schließen">
|
||||
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 2.5 }}>
|
||||
{this.renderContent()}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{hasText && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={copied ? <CheckRoundedIcon /> : <ContentCopyRoundedIcon />}
|
||||
onClick={this.handleCopyText}
|
||||
color={copied ? 'success' : 'inherit'}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
{copied ? 'Kopiert' : 'Text kopieren'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<DownloadRoundedIcon />}
|
||||
onClick={this.handleDownload}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
Herunterladen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,17 @@ import AddRoundedIcon from '@mui/icons-material/AddRounded';
|
||||
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
|
||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||
import ChatFlowList from './ChatFlowList';
|
||||
import AskUserPrompt from './AskUserPrompt';
|
||||
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
||||
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
||||
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
|
||||
import {
|
||||
BLUE_GLOW,
|
||||
SHADOW_DRAWER,
|
||||
@@ -50,10 +57,16 @@ export default class ChatDrawer extends PureComponent {
|
||||
sendError: s.sendError,
|
||||
activeTurns: s.activeTurns || [],
|
||||
inputText: '',
|
||||
stagedAttachments: [],
|
||||
isProcessingFiles: false,
|
||||
isDraggingFiles: false,
|
||||
attachmentError: null,
|
||||
previewAttachment: null,
|
||||
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
||||
isResizing: false,
|
||||
};
|
||||
this.inputRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
this.focusTimeout = null;
|
||||
}
|
||||
|
||||
@@ -204,17 +217,122 @@ export default class ChatDrawer extends PureComponent {
|
||||
handleSend = async (e) => {
|
||||
e?.preventDefault();
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.conversationState === 'busy' || this.state.isSending) {
|
||||
const attachments = this.state.stagedAttachments || [];
|
||||
if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) {
|
||||
return;
|
||||
}
|
||||
this.setState({ inputText: '' });
|
||||
this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
|
||||
try {
|
||||
await chatAutomat.actions.sendMessage(text);
|
||||
await chatAutomat.actions.sendMessage(text, attachments);
|
||||
} catch {
|
||||
// Error is tracked in chatAutomat state (sendError)
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenFileDialog = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
handleFileSelect = (e) => {
|
||||
const files = e.target?.files;
|
||||
if (files && files.length > 0) {
|
||||
void this.handleFiles(Array.from(files));
|
||||
}
|
||||
if (e.target) {
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
handleFiles = async (fileList) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
this.setState({ isProcessingFiles: true, attachmentError: null });
|
||||
|
||||
const newAttachments = [];
|
||||
const errors = [];
|
||||
|
||||
for (const file of fileList) {
|
||||
try {
|
||||
const att = await readFileAttachment(file);
|
||||
newAttachments.push(att);
|
||||
} catch (err) {
|
||||
errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState((prev) => ({
|
||||
stagedAttachments: [...prev.stagedAttachments, ...newAttachments],
|
||||
isProcessingFiles: false,
|
||||
attachmentError: errors.length > 0 ? errors.join(' ') : null,
|
||||
}));
|
||||
this.focusInput(50);
|
||||
};
|
||||
|
||||
handleRemoveAttachment = (idOrIdx) => {
|
||||
this.setState((prev) => ({
|
||||
stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)),
|
||||
}));
|
||||
};
|
||||
|
||||
handlePreviewAttachment = (attachment) => {
|
||||
this.setState({ previewAttachment: attachment });
|
||||
};
|
||||
|
||||
handleClosePreview = () => {
|
||||
this.setState({ previewAttachment: null });
|
||||
};
|
||||
|
||||
handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
const files = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
void this.handleFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
this.setState({ isDraggingFiles: true });
|
||||
}
|
||||
};
|
||||
|
||||
handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) {
|
||||
this.setState({ isDraggingFiles: true });
|
||||
}
|
||||
};
|
||||
|
||||
handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
this.setState({ isDraggingFiles: false });
|
||||
};
|
||||
|
||||
handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ isDraggingFiles: false });
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
void this.handleFiles(Array.from(files));
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -449,26 +567,37 @@ export default class ChatDrawer extends PureComponent {
|
||||
draftConversation,
|
||||
inputText,
|
||||
sendError,
|
||||
stagedAttachments,
|
||||
isProcessingFiles,
|
||||
isDraggingFiles,
|
||||
attachmentError,
|
||||
previewAttachment,
|
||||
} = this.state;
|
||||
|
||||
const pendingAsk = this.getPendingAskUser();
|
||||
const isBusy = conversationState === 'busy';
|
||||
const isWorking = isBusy || isSending;
|
||||
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
|
||||
const canSend = !isWorking && canChat && Boolean(inputText.trim());
|
||||
const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={this.handleSend}
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDrop={this.handleDrop}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: isDraggingFiles ? 'primary.main' : 'divider',
|
||||
bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
|
||||
transition: 'border-color 0.15s, background-color 0.15s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{sendError && (
|
||||
@@ -495,7 +624,105 @@ export default class ChatDrawer extends PureComponent {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{attachmentError && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'warning.light',
|
||||
color: 'warning.contrastText',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">{attachmentError}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.setState({ attachmentError: null })}
|
||||
sx={{ color: 'inherit', p: 0.2 }}
|
||||
>
|
||||
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Staged attachments tray */}
|
||||
<ChatStagedAttachmentsTray
|
||||
attachments={stagedAttachments}
|
||||
onRemove={this.handleRemoveAttachment}
|
||||
onPreview={this.handlePreviewAttachment}
|
||||
/>
|
||||
|
||||
{isProcessingFiles && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
|
||||
<CircularProgress size={14} thickness={5} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
|
||||
Dateien werden eingelesen…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isDraggingFiles && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
border: '2px dashed',
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 2,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'action.selected',
|
||||
color: 'primary.main',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
||||
{/* Attachment Icon Button */}
|
||||
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleOpenFileDialog}
|
||||
disabled={!canChat || isWorking}
|
||||
aria-label="Dateien anhängen"
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2.5,
|
||||
color: stagedAttachments.length > 0 ? 'primary.main' : 'text.secondary',
|
||||
bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Badge badgeContent={stagedAttachments.length} color="primary">
|
||||
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT_STRING}
|
||||
onChange={this.handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
inputRef={this.inputRef}
|
||||
multiline
|
||||
@@ -504,11 +731,14 @@ export default class ChatDrawer extends PureComponent {
|
||||
value={inputText}
|
||||
onChange={(e) => this.setState({ inputText: e.target.value })}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
disabled={!canChat}
|
||||
placeholder={
|
||||
pendingAsk
|
||||
? 'Bitte Frage oben beantworten…'
|
||||
: undefined
|
||||
: stagedAttachments.length > 0
|
||||
? 'Nachricht hinzufügen oder direkt absenden…'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -527,6 +757,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2.5,
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
},
|
||||
@@ -544,6 +775,13 @@ export default class ChatDrawer extends PureComponent {
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Modal for previewing staged attachments */}
|
||||
<ChatAttachmentPreviewModal
|
||||
open={Boolean(previewAttachment)}
|
||||
attachment={previewAttachment}
|
||||
onClose={this.handleClosePreview}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
||||
import HealingMarkdown from './HealingMarkdown';
|
||||
import { ChatMessageAttachments } from './ChatAttachmentItems';
|
||||
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
|
||||
import {
|
||||
CHROMA,
|
||||
SHADOW_BUBBLE_ASSISTANT,
|
||||
@@ -725,12 +727,30 @@ export function normalizeChatMessages(rawMessages = []) {
|
||||
}
|
||||
|
||||
const payload = parseMessagePayload(msg.message);
|
||||
let attachments = Array.isArray(msg.attachments) ? [...msg.attachments] : [];
|
||||
if (attachments.length === 0 && Array.isArray(payload.attachments)) {
|
||||
attachments = [...payload.attachments];
|
||||
}
|
||||
let userText = payload.content !== undefined ? payload.content : msg.message;
|
||||
|
||||
if (typeof userText === 'string' && userText.startsWith('{')) {
|
||||
try {
|
||||
const nested = JSON.parse(userText);
|
||||
if (nested && typeof nested === 'object') {
|
||||
if (nested.content !== undefined) userText = nested.content;
|
||||
if (Array.isArray(nested.attachments) && attachments.length === 0) {
|
||||
attachments = [...nested.attachments];
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
flushAssistant();
|
||||
result.push({
|
||||
...msg,
|
||||
message: payload.content !== undefined ? payload.content : msg.message,
|
||||
message: userText,
|
||||
attachments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -826,7 +846,7 @@ export function normalizeChatMessages(rawMessages = []) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function MessageRow({ msg, showTime = true }) {
|
||||
function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
|
||||
// If the message has structured turns, render each turn
|
||||
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
|
||||
return (
|
||||
@@ -839,7 +859,22 @@ function MessageRow({ msg, showTime = true }) {
|
||||
}
|
||||
|
||||
const payload = parseMessagePayload(msg.message);
|
||||
const textContent = payload.content !== undefined ? payload.content : msg.message;
|
||||
let textContent = payload.content !== undefined ? payload.content : msg.message;
|
||||
let attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
|
||||
if (attachments.length === 0 && Array.isArray(payload.attachments)) {
|
||||
attachments = payload.attachments;
|
||||
}
|
||||
if (typeof textContent === 'string' && textContent.startsWith('{')) {
|
||||
try {
|
||||
const nested = JSON.parse(textContent);
|
||||
if (nested && typeof nested === 'object') {
|
||||
if (nested.content !== undefined) textContent = nested.content;
|
||||
if (Array.isArray(nested.attachments) && attachments.length === 0) {
|
||||
attachments = nested.attachments;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (msg.role === 'system') {
|
||||
return (
|
||||
@@ -917,11 +952,21 @@ function MessageRow({ msg, showTime = true }) {
|
||||
lineHeight: 1.5,
|
||||
})}
|
||||
>
|
||||
{isUser ? (
|
||||
textContent
|
||||
) : (
|
||||
<HealingMarkdown text={textContent} streaming={msg.streaming} />
|
||||
{attachments && attachments.length > 0 && (
|
||||
<ChatMessageAttachments
|
||||
attachments={attachments}
|
||||
isUser={isUser}
|
||||
onPreview={onPreviewAttachment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{textContent ? (
|
||||
isUser ? (
|
||||
textContent
|
||||
) : (
|
||||
<HealingMarkdown text={textContent} streaming={msg.streaming} />
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -933,6 +978,18 @@ export default class ChatFlowList extends PureComponent {
|
||||
bottomRef = createRef();
|
||||
scrollTimeout = null;
|
||||
|
||||
state = {
|
||||
previewAttachment: null,
|
||||
};
|
||||
|
||||
handlePreviewAttachment = (attachment) => {
|
||||
this.setState({ previewAttachment: attachment });
|
||||
};
|
||||
|
||||
handleClosePreview = () => {
|
||||
this.setState({ previewAttachment: null });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.scrollToBottom('auto');
|
||||
}
|
||||
@@ -974,6 +1031,7 @@ export default class ChatFlowList extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { messages = [], activeTurns = [] } = this.props;
|
||||
const { previewAttachment } = this.state;
|
||||
const normalizedMessages = normalizeChatMessages(messages);
|
||||
|
||||
if (normalizedMessages.length === 0 && activeTurns.length === 0) {
|
||||
@@ -1007,6 +1065,7 @@ export default class ChatFlowList extends PureComponent {
|
||||
key={msg.id || index}
|
||||
msg={msg}
|
||||
showTime={showTime}
|
||||
onPreviewAttachment={this.handlePreviewAttachment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1019,6 +1078,12 @@ export default class ChatFlowList extends PureComponent {
|
||||
: null}
|
||||
|
||||
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
||||
|
||||
<ChatAttachmentPreviewModal
|
||||
open={Boolean(previewAttachment)}
|
||||
attachment={previewAttachment}
|
||||
onClose={this.handleClosePreview}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
198
src/components/chat/chatAttachments.js
Normal file
198
src/components/chat/chatAttachments.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @file chatAttachments.js
|
||||
* @description Helpers for handling file attachments in the AI chat window.
|
||||
* Supports: images, PDFs, CSV, plain text, XML, and HTML.
|
||||
*/
|
||||
|
||||
export const SUPPORTED_CATEGORIES = {
|
||||
IMAGE: 'image',
|
||||
PDF: 'pdf',
|
||||
CSV: 'csv',
|
||||
TEXT: 'text',
|
||||
XML: 'xml',
|
||||
HTML: 'html',
|
||||
};
|
||||
|
||||
export const ACCEPT_STRING = [
|
||||
'image/*',
|
||||
'application/pdf',
|
||||
'.pdf',
|
||||
'text/csv',
|
||||
'.csv',
|
||||
'text/plain',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.log',
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
'.xml',
|
||||
'text/html',
|
||||
'.html',
|
||||
'.htm',
|
||||
].join(',');
|
||||
|
||||
export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/**
|
||||
* Determine the attachment category based on MIME type or file extension.
|
||||
* @param {File|{ name: string, type?: string }} file
|
||||
* @returns {'image'|'pdf'|'csv'|'text'|'xml'|'html'|'unknown'}
|
||||
*/
|
||||
export function categorizeFile(file) {
|
||||
const name = (file?.name || '').toLowerCase();
|
||||
const type = (file?.type || '').toLowerCase();
|
||||
|
||||
// Images
|
||||
if (type.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(name)) {
|
||||
return SUPPORTED_CATEGORIES.IMAGE;
|
||||
}
|
||||
|
||||
// PDFs
|
||||
if (type === 'application/pdf' || name.endsWith('.pdf')) {
|
||||
return SUPPORTED_CATEGORIES.PDF;
|
||||
}
|
||||
|
||||
// CSV
|
||||
if (
|
||||
type === 'text/csv' ||
|
||||
type === 'application/csv' ||
|
||||
type === 'text/comma-separated-values' ||
|
||||
name.endsWith('.csv')
|
||||
) {
|
||||
return SUPPORTED_CATEGORIES.CSV;
|
||||
}
|
||||
|
||||
// XML
|
||||
if (type === 'text/xml' || type === 'application/xml' || name.endsWith('.xml')) {
|
||||
return SUPPORTED_CATEGORIES.XML;
|
||||
}
|
||||
|
||||
// HTML
|
||||
if (type === 'text/html' || name.endsWith('.html') || name.endsWith('.htm')) {
|
||||
return SUPPORTED_CATEGORIES.HTML;
|
||||
}
|
||||
|
||||
// Plain Text / Markdown / Log
|
||||
if (
|
||||
type.startsWith('text/') ||
|
||||
type === 'application/json' ||
|
||||
/\.(txt|md|log|json|ini|env|yaml|yml)$/.test(name)
|
||||
) {
|
||||
return SUPPORTED_CATEGORIES.TEXT;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable format of file size in B, KB, or MB.
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes || Number.isNaN(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a browser File object and convert it to a structured attachment object.
|
||||
* @param {File} file
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function readFileAttachment(file) {
|
||||
const category = categorizeFile(file);
|
||||
if (category === 'unknown') {
|
||||
throw new Error(`Das Dateiformat von "${file.name}" wird nicht unterstützt. Erlaubt sind Bilder, PDFs, CSV, Text, XML und HTML.`);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`Die Datei "${file.name}" ist mit ${formatFileSize(file.size)} zu groß (maximal 25 MB).`);
|
||||
}
|
||||
|
||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// For text-based formats, read as text and dataUrl
|
||||
const isTextBased =
|
||||
category === SUPPORTED_CATEGORIES.CSV ||
|
||||
category === SUPPORTED_CATEGORIES.TEXT ||
|
||||
category === SUPPORTED_CATEGORIES.XML ||
|
||||
category === SUPPORTED_CATEGORIES.HTML;
|
||||
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error(`Fehler beim Lesen der Datei "${file.name}"`));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
let textContent = '';
|
||||
let previewSnippet = '';
|
||||
|
||||
if (isTextBased) {
|
||||
try {
|
||||
textContent = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error(`Fehler beim Lesen von "${file.name}" als Text`));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
// First 25 lines or 2000 chars as preview
|
||||
previewSnippet = textContent.slice(0, 2000);
|
||||
} catch {
|
||||
// Non-fatal if text reading fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type || getMimeTypeFallback(category, file.name),
|
||||
category,
|
||||
dataUrl,
|
||||
textContent: textContent || undefined,
|
||||
previewSnippet: previewSnippet || undefined,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback MIME type based on extension/category.
|
||||
*/
|
||||
function getMimeTypeFallback(category, filename) {
|
||||
const ext = (filename.split('.').pop() || '').toLowerCase();
|
||||
switch (category) {
|
||||
case SUPPORTED_CATEGORIES.IMAGE:
|
||||
if (ext === 'png') return 'image/png';
|
||||
if (ext === 'webp') return 'image/webp';
|
||||
if (ext === 'svg') return 'image/svg+xml';
|
||||
if (ext === 'gif') return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
case SUPPORTED_CATEGORIES.PDF:
|
||||
return 'application/pdf';
|
||||
case SUPPORTED_CATEGORIES.CSV:
|
||||
return 'text/csv';
|
||||
case SUPPORTED_CATEGORIES.XML:
|
||||
return 'application/xml';
|
||||
case SUPPORTED_CATEGORIES.HTML:
|
||||
return 'text/html';
|
||||
default:
|
||||
return 'text/plain';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger download of an attachment in the browser.
|
||||
* @param {object} attachment
|
||||
*/
|
||||
export function downloadAttachment(attachment) {
|
||||
if (!attachment) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = attachment.dataUrl || (attachment.url ? attachment.url : '');
|
||||
link.download = attachment.name || 'anhang';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
Reference in New Issue
Block a user