u
This commit is contained in:
381
src/components/manifest/TextEditorPanel.jsx
Normal file
381
src/components/manifest/TextEditorPanel.jsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
|
||||
/**
|
||||
* Inline text-file viewer/editor rendered as a full-width panel tile below the
|
||||
* manifest header ("Kopfdaten"). Mount it only while a text attachment is
|
||||
* selected; unmounting closes it.
|
||||
*/
|
||||
export class TextEditorPanel extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
content: '',
|
||||
initialContent: '',
|
||||
loading: false,
|
||||
};
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handleCopy = this.handleCopy.bind(this);
|
||||
this.handleDownload = this.handleDownload.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadContent();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.attachment !== prevProps.attachment) {
|
||||
this.loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
async loadContent() {
|
||||
const { attachment, docId, onContentLoad } = this.props;
|
||||
if (!attachment) return;
|
||||
|
||||
if (typeof attachment.text === 'string') {
|
||||
this.setState({
|
||||
content: attachment.text,
|
||||
initialContent: attachment.text,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(attachment.file instanceof Blob || attachment.file instanceof File) &&
|
||||
attachment.file.size > 0
|
||||
) {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const text = await attachment.file.text();
|
||||
this.setState({
|
||||
content: text,
|
||||
initialContent: text,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Lesen der Textdatei:', err);
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
|
||||
const effectiveDocId = docId ?? attachment.manifestId;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (effectiveDocId != null && fileId != null) {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const getFile =
|
||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
||||
deliveryApi.getFile;
|
||||
const blob = await getFile(effectiveDocId, fileId);
|
||||
const text = await blob.text();
|
||||
this.setState({
|
||||
content: text,
|
||||
initialContent: text,
|
||||
loading: false,
|
||||
});
|
||||
onContentLoad?.(attachment, text, blob);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Textdatei:', err);
|
||||
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
|
||||
this.setState({ loading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
content: '',
|
||||
initialContent: '',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
this.setState({ content: e.target.value });
|
||||
}
|
||||
|
||||
handleSave() {
|
||||
const { onSave, onClose } = this.props;
|
||||
const { content } = this.state;
|
||||
onSave?.(content);
|
||||
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
async handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.state.content);
|
||||
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
|
||||
} catch {
|
||||
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
handleDownload() {
|
||||
const { attachment } = this.props;
|
||||
const { content } = this.state;
|
||||
if (!content) return;
|
||||
const filename = attachment?.name || 'Dokument.txt';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
onClose,
|
||||
attachment,
|
||||
readOnly = false,
|
||||
wide = 3,
|
||||
position = 'top',
|
||||
onPositionChange,
|
||||
height = '60vh',
|
||||
} = this.props;
|
||||
if (!attachment) return null;
|
||||
|
||||
const { content, initialContent, loading } = this.state;
|
||||
const filename = attachment?.name || 'Dokument.txt';
|
||||
const isDirty = content !== initialContent;
|
||||
const linesCount = content ? content.split('\n').length : 0;
|
||||
const charCount = content ? content.length : 0;
|
||||
|
||||
return (
|
||||
<PanelTile
|
||||
wide={wide}
|
||||
minHeight={320}
|
||||
color="#f8fafc"
|
||||
sx={{ height, minHeight: 320 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
mb: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
|
||||
<TextSnippetOutlinedIcon color="primary" />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} noWrap>
|
||||
{filename}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{linesCount} Zeilen · {charCount} Zeichen
|
||||
</Typography>
|
||||
</Box>
|
||||
{isDirty && (
|
||||
<Chip
|
||||
label="Ungespeichert"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
<Tooltip title="Über der Artikelliste">
|
||||
<IconButton
|
||||
size="small"
|
||||
color={position === 'top' ? 'primary' : 'default'}
|
||||
aria-label="Über der Artikelliste"
|
||||
onClick={() => onPositionChange?.('top')}
|
||||
>
|
||||
<VerticalAlignTopIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Rechts neben der Artikelliste">
|
||||
<IconButton
|
||||
size="small"
|
||||
color={position === 'right' ? 'primary' : 'default'}
|
||||
aria-label="Rechts neben der Artikelliste"
|
||||
onClick={() => onPositionChange?.('right')}
|
||||
>
|
||||
<ViewSidebarIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Unter der Artikelliste">
|
||||
<IconButton
|
||||
size="small"
|
||||
color={position === 'below' ? 'primary' : 'default'}
|
||||
aria-label="Unter der Artikelliste"
|
||||
onClick={() => onPositionChange?.('below')}
|
||||
>
|
||||
<VerticalAlignBottomIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(248, 250, 252, 0.7)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={36} />
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
value={content}
|
||||
disabled={loading}
|
||||
onChange={this.handleChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
wrap: 'off',
|
||||
spellCheck: false,
|
||||
},
|
||||
input: {
|
||||
readOnly,
|
||||
sx: {
|
||||
height: '100%',
|
||||
alignItems: 'flex-start',
|
||||
fontFamily:
|
||||
'"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1.45,
|
||||
tabSize: 4,
|
||||
bgcolor: '#ffffff',
|
||||
p: 1.5,
|
||||
'& textarea': {
|
||||
height: '100% !important',
|
||||
whiteSpace: 'pre !important',
|
||||
overflowWrap: 'normal !important',
|
||||
wordWrap: 'normal !important',
|
||||
wordBreak: 'normal !important',
|
||||
overflowX: 'auto !important',
|
||||
overflowY: 'auto !important',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .MuiInputBase-root': {
|
||||
flex: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
mt: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={this.handleCopy}
|
||||
disabled={loading || !content}
|
||||
>
|
||||
Kopieren
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<DownloadOutlinedIcon />}
|
||||
onClick={this.handleDownload}
|
||||
disabled={loading || !content}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button size="small" onClick={onClose}>
|
||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<SaveOutlinedIcon />}
|
||||
onClick={this.handleSave}
|
||||
disabled={loading || !isDirty}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TextEditorPanel;
|
||||
Reference in New Issue
Block a user