This commit is contained in:
seb
2026-07-31 17:51:57 +02:00
parent 797ea59bed
commit a6a9777f31
33 changed files with 1432 additions and 422 deletions

View File

@@ -0,0 +1,204 @@
import { Component, createRef } from 'react';
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import { backendErrorFrom } from '../../utils/backendError';
import { panelTilePaperSx } from './panelTileLayout';
function valuesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
*/
export class EditablePanelTile extends Component {
constructor(props) {
super(props);
this.inputRef = createRef();
this.state = {
editing: false,
draft: null,
saving: false,
error: null,
};
}
componentDidUpdate(_prevProps, prevState) {
if (!prevState.editing && this.state.editing) {
this.inputRef.current?.focus();
}
}
getSavedValue() {
return this.props.value;
}
isDirty() {
const { draft, editing } = this.state;
if (!editing || draft == null) return false;
if (this.props.isDirty) {
return this.props.isDirty(draft, this.getSavedValue());
}
return !valuesEqual(draft, this.getSavedValue());
}
canSave() {
const { draft, saving } = this.state;
if (saving || !this.isDirty() || draft == null) return false;
if (this.props.canSave) {
return this.props.canSave(draft, this.getSavedValue());
}
return true;
}
handleStartEdit = (event) => {
event.stopPropagation();
this.setState({
editing: true,
draft: structuredClone(this.getSavedValue()),
error: null,
});
};
handleCancel = (event) => {
event.stopPropagation();
this.setState({
editing: false,
draft: null,
error: null,
});
};
handleDraftChange = (nextDraft) => {
this.setState({ draft: nextDraft, error: null });
};
handleSave = async (event) => {
event.stopPropagation();
if (!this.canSave()) return;
const { onSave } = this.props;
const { draft } = this.state;
this.setState({ saving: true, error: null });
try {
await onSave(draft);
this.setState({ editing: false, draft: null, saving: false, error: null });
enqueueSnackbar('Gespeichert.', { variant: 'success' });
} catch (err) {
const normalized = backendErrorFrom(err);
this.setState({ saving: false, error: normalized.message });
enqueueSnackbar(normalized.message, { variant: 'error' });
}
};
renderTitle() {
const { title } = this.props;
const { editing } = this.state;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1, pr: editing ? 7 : 4 }}
>
{title}
</Typography>
);
}
renderActions() {
const { onSave } = this.props;
if (!onSave) return null;
const { editing, saving } = this.state;
const actionSx = {
position: 'absolute',
top: 4,
color: 'text.secondary',
};
if (!editing) {
return (
<IconButton
size="small"
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
);
}
return (
<>
<IconButton
size="small"
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...actionSx, right: 40 }}
>
<CloseOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={this.handleSave}
disabled={!this.canSave()}
aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'}
sx={{ ...actionSx, right: 4 }}
>
{saving ? (
<CircularProgress size={18} color="inherit" />
) : (
<SaveOutlinedIcon fontSize="small" />
)}
</IconButton>
</>
);
}
render() {
const {
wide = 1,
sx,
minHeight = 120,
renderDisplay,
renderEdit,
} = this.props;
const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue();
const pending = this.isDirty();
return (
<Paper
variant="outlined"
sx={panelTilePaperSx({ wide, sx, minHeight, pending })}
>
{this.renderActions()}
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}>
{editing && draft != null
? renderEdit(draft, this.handleDraftChange, this.inputRef)
: renderDisplay(savedValue)}
</Box>
{error ? (
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
{error}
</Alert>
) : null}
</Paper>
);
}
}

View File

@@ -0,0 +1,56 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { GRID_COLUMNS, panelTilePaperSx } from './panelTileLayout';
export class PanelTile extends Component {
renderTitle() {
const { title } = this.props;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1 }}
>
{title}
</Typography>
);
}
render() {
const { wide = 1, children, sx, minHeight = 120 } = this.props;
return (
<Paper
variant="outlined"
sx={panelTilePaperSx({ wide, sx, minHeight })}
>
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Paper>
);
}
}
export function PanelTileGrid({ children, sx }) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
gap: 2,
...sx,
}}
>
{children}
</Box>
);
}
export { GRID_COLUMNS };

View File

@@ -0,0 +1,3 @@
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
export { EditablePanelTile } from './EditablePanelTile';
export { PENDING_EDIT_BORDER } from './panelTileLayout';

View File

@@ -0,0 +1,37 @@
const GRID_COLUMNS = 4;
export const PENDING_EDIT_BORDER = '#ec4899';
function clampWide(wide) {
return Math.min(Math.max(wide, 1), 3);
}
export function responsiveColumnSpan(wide) {
const span = clampWide(wide);
return {
xs: '1 / -1',
sm: span >= 2 ? 'span 2' : 'span 1',
md: `span ${span}`,
};
}
export const PENDING_EDIT_RING_PX = 4;
export function panelTilePaperSx({ wide, sx, minHeight = 120, pending = false }) {
return {
gridColumn: responsiveColumnSpan(wide),
position: 'relative',
p: 2,
minHeight,
display: 'flex',
flexDirection: 'column',
...(pending
? {
boxShadow: `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${PENDING_EDIT_BORDER}`,
}
: {}),
...sx,
};
}
export { GRID_COLUMNS };