u
This commit is contained in:
149
src/components/applet/PinnedAppletDialog.jsx
Normal file
149
src/components/applet/PinnedAppletDialog.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import pinnedAppletAutomat from '../../automats/pinnedAppletAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
import { vfs as vfsApi } from '../../api';
|
||||
import VfsAppletRunner from '../vfs/VfsAppletRunner';
|
||||
import VfsTextEditor from '../vfs/VfsTextEditor';
|
||||
|
||||
/**
|
||||
* @file PinnedAppletDialog.jsx
|
||||
* @description Dialog modal that executes a pinned applet runner directly on the current route.
|
||||
*/
|
||||
export default class PinnedAppletDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const activeExecution = pinnedAppletAutomat.state.activeExecution;
|
||||
this.state = {
|
||||
activeExecution,
|
||||
content: '',
|
||||
loadingContent: Boolean(activeExecution),
|
||||
loadError: null,
|
||||
viewMode: 'run',
|
||||
files: vfsAutomat.state.files,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubPinned = pinnedAppletAutomat.subscribe(this, (state) => ({
|
||||
activeExecution: state.activeExecution,
|
||||
}));
|
||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||
files: state.files,
|
||||
}));
|
||||
if (this.state.activeExecution) {
|
||||
void this.loadAppletContent(this.state.activeExecution);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.activeExecution !== this.state.activeExecution) {
|
||||
if (this.state.activeExecution) {
|
||||
this.setState({ viewMode: 'run' });
|
||||
void this.loadAppletContent(this.state.activeExecution);
|
||||
} else {
|
||||
this.setState({ content: '', loadingContent: false, loadError: null, viewMode: 'run' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubPinned?.();
|
||||
this.unsubVfs?.();
|
||||
}
|
||||
|
||||
loadAppletContent = async (fileName) => {
|
||||
this.setState({ loadingContent: true, loadError: null });
|
||||
try {
|
||||
const text = await vfsApi.getText(fileName);
|
||||
this.setState({ content: text, loadingContent: false });
|
||||
} catch (err) {
|
||||
console.error('[PinnedAppletDialog] failed to load applet content:', err);
|
||||
this.setState({
|
||||
loadError: err?.message || 'Fehler beim Laden des Applets',
|
||||
loadingContent: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
pinnedAppletAutomat.actions.closeExecution();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeExecution, content, loadingContent, loadError, viewMode, files } = this.state;
|
||||
if (!activeExecution) return null;
|
||||
|
||||
const file = files.find((f) => f.name === activeExecution);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(activeExecution)}
|
||||
onClose={this.handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
height: '85vh',
|
||||
maxHeight: 800,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.paper',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{loadingContent ? (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: 'text.secondary',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
<Typography variant="body2">Lade Applet {activeExecution}…</Typography>
|
||||
</Box>
|
||||
) : loadError ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="body1">
|
||||
{loadError}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : viewMode === 'run' ? (
|
||||
<VfsAppletRunner
|
||||
fileName={activeExecution}
|
||||
file={file}
|
||||
content={content}
|
||||
loadingContent={false}
|
||||
onClose={this.handleClose}
|
||||
onShowCode={() => this.setState({ viewMode: 'code' })}
|
||||
/>
|
||||
) : (
|
||||
<VfsTextEditor
|
||||
fileName={activeExecution}
|
||||
file={file}
|
||||
content={content}
|
||||
originalContent={content}
|
||||
isDirty={false}
|
||||
loadingContent={false}
|
||||
onClose={this.handleClose}
|
||||
onRunApplet={() => this.setState({ viewMode: 'run' })}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
154
src/components/applet/PinnedAppletFabs.jsx
Normal file
154
src/components/applet/PinnedAppletFabs.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import pinnedAppletAutomat, { normalizeRoute } from '../../automats/pinnedAppletAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
|
||||
/**
|
||||
* @file PinnedAppletFabs.jsx
|
||||
* @description Renders Floating Action Buttons for any applets pinned to the current browser route.
|
||||
* Clicking a FAB triggers the execution dialog for that applet.
|
||||
*/
|
||||
export default class PinnedAppletFabs extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const route = normalizeRoute(props.pathname);
|
||||
this.state = {
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
files: vfsAutomat.state.files,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubPinned = pinnedAppletAutomat.subscribe(this, () => {
|
||||
const route = normalizeRoute(this.props.pathname);
|
||||
return {
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
};
|
||||
});
|
||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||
files: state.files,
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.pathname !== this.props.pathname) {
|
||||
const route = normalizeRoute(this.props.pathname);
|
||||
this.setState({
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubPinned?.();
|
||||
this.unsubVfs?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { pinnedFileNames, files } = this.state;
|
||||
if (!pinnedFileNames || pinnedFileNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
position: 'fixed',
|
||||
bottom: { xs: 20, sm: 28 },
|
||||
right: { xs: 20, sm: 28 },
|
||||
zIndex: theme.zIndex.speedDial || 1050,
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
alignItems: 'flex-end',
|
||||
gap: 1.5,
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
{pinnedFileNames.map((fileName) => {
|
||||
const file = files.find((f) => f.name === fileName);
|
||||
const baseName = fileName.split('/').pop().replace(/\.applet\.js$/i, '');
|
||||
const title = file?.description || baseName;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={fileName}
|
||||
placement="left"
|
||||
arrow
|
||||
title={
|
||||
<Box sx={{ p: 0.5, maxWidth: 280 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ fontSize: '0.82rem' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', opacity: 0.8, fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
{fileName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="primary.light"
|
||||
sx={{ display: 'block', mt: 0.5, fontStyle: 'italic', fontWeight: 600 }}
|
||||
>
|
||||
Klicken zum Ausführen
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
badgeContent={
|
||||
<PushPinIcon
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
transform: 'rotate(25deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
pointerEvents: 'auto',
|
||||
'& .MuiBadge-badge': {
|
||||
bgcolor: 'primary.dark',
|
||||
border: '2px solid #fff',
|
||||
borderRadius: '50%',
|
||||
width: 20,
|
||||
height: 20,
|
||||
minWidth: 20,
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label={`Applet ${title} ausführen`}
|
||||
onClick={() => pinnedAppletAutomat.actions.executeApplet(fileName)}
|
||||
sx={(theme) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.25)',
|
||||
transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.08) translateY(-2px)',
|
||||
boxShadow: '0 10px 26px rgba(0, 0, 0, 0.35)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<PlayArrowRoundedIcon sx={{ fontSize: 28 }} />
|
||||
</Fab>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/components/applet/index.js
Normal file
2
src/components/applet/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as PinnedAppletFabs } from './PinnedAppletFabs';
|
||||
export { default as PinnedAppletDialog } from './PinnedAppletDialog';
|
||||
Reference in New Issue
Block a user