This commit is contained in:
seb
2026-10-15 20:15:19 +02:00
parent 8e783a571f
commit beed294cb0
26 changed files with 3165 additions and 906 deletions

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

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

View File

@@ -0,0 +1,2 @@
export { default as PinnedAppletFabs } from './PinnedAppletFabs';
export { default as PinnedAppletDialog } from './PinnedAppletDialog';