This commit is contained in:
seb
2026-10-06 11:40:02 +02:00
parent 03608017f3
commit 4769a87c54
6 changed files with 1197 additions and 20 deletions

View 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>
);
}
}