This commit is contained in:
seb
2026-07-31 20:10:23 +02:00
parent 7cd053ada6
commit d5d30fb286
9 changed files with 349 additions and 37 deletions

View File

@@ -0,0 +1,28 @@
import Box from '@mui/material/Box';
export default function ArticlePhoto({ imageUrl }) {
return (
<Box
sx={{
width: 200,
height: 200,
maxWidth: '100%',
borderRadius: 2,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: imageUrl ? 'transparent' : 'action.hover',
}}
>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,82 @@
import { useRef, useState } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import { article } from '../../api';
import { PanelTile } from '../panels';
import { requireObject } from '../../utils/apiShape';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
export default function ArticlePhotoTile({
article: articleData,
onArticleUpdated,
wide = 1,
minHeight = 0,
sx,
tileKey,
}) {
const fileInputRef = useRef(null);
const [busy, setBusy] = useState(false);
const photo = primaryPhoto(articleData);
const imageUrl = photo?.url;
const uploadRawImage = async (rawBlob) => {
setBusy(true);
try {
const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadPhoto(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/photos');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
} finally {
setBusy(false);
}
};
const handlePasteClick = () => {
if (busy) return;
void pasteImageOrFallback(() => {
fileInputRef.current?.click();
}).then((image) => {
if (image) void uploadRawImage(image);
});
};
const handleFileChange = (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void uploadRawImage(file);
};
return (
<>
<PanelTile
wide={wide}
minHeight={minHeight}
sx={sx}
tileKey={tileKey}
action={
imageUrl
? undefined
: {
label: 'Bild einfügen',
icon: ContentPasteIcon,
onClick: handlePasteClick,
busy,
}
}
>
<ArticlePhoto imageUrl={imageUrl} />
</PanelTile>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleFileChange}
/>
</>
);
}

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../SingleLineTextEdit';
import ArticlePhotoTile from './ArticlePhotoTile';
import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireObject } from '../../utils/apiShape';
@@ -32,20 +33,6 @@ function PanelValue({ label, children }) {
);
}
function ArticlePhoto() {
return (
<Box
sx={{
width: 200,
height: 200,
maxWidth: '100%',
bgcolor: 'action.hover',
borderRadius: 2,
}}
/>
);
}
const headerTileSx = {
minHeight: 0,
};
@@ -155,9 +142,14 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
)}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.photo} minHeight={0} sx={headerTileSx}>
<ArticlePhoto />
</PanelTile>
<ArticlePhotoTile
wide={1}
tileKey={PANEL_TILE_KEYS.photo}
minHeight={0}
sx={headerTileSx}
article={articleData}
onArticleUpdated={onArticleUpdated}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.stock} title="Bestand">
<PanelValue>

View File

@@ -11,14 +11,12 @@ import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme';
import { enqueueSnackbar } from 'notistack';
import { backendErrorFrom } from '../../utils/backendError';
import { panelTilePaperSx } from './panelTileLayout';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
function valuesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
const ACTION_PADDING = 12;
/**
* Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
@@ -140,12 +138,7 @@ class EditablePanelTileBase extends Component {
if (!onSave) return null;
const { editing, saving } = this.state;
const actionSx = {
position: 'absolute',
top: 4,
color: 'text.secondary',
};
const iconSx = { fontSize: 28 };
const iconSx = tileActionIconSx;
if (!editing) {
return (
@@ -155,7 +148,7 @@ class EditablePanelTileBase extends Component {
aria-hidden
tabIndex={-1}
sx={{
...actionSx,
...tileActionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
@@ -166,7 +159,7 @@ class EditablePanelTileBase extends Component {
<IconButton
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }}
sx={{ ...tileActionSx, right: 4 }}
>
<EditOutlinedIcon sx={iconSx} />
</IconButton>
@@ -181,7 +174,7 @@ class EditablePanelTileBase extends Component {
disabled={!this.canSave()}
aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'}
sx={{ ...actionSx, right: 48 }}
sx={{ ...tileActionSx, right: 48 }}
>
{saving ? (
<CircularProgress size={22} color="inherit" />
@@ -193,7 +186,7 @@ class EditablePanelTileBase extends Component {
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...actionSx, right: 4 }}
sx={{ ...tileActionSx, right: 4 }}
>
<CloseOutlinedIcon sx={iconSx} />
</IconButton>
@@ -207,7 +200,7 @@ class EditablePanelTileBase extends Component {
const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue();
const pending = this.isDirty();
const actionPadding = onSave ? ACTION_PADDING : 0;
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
return (
<Paper

View File

@@ -1,27 +1,75 @@
import { Component } from 'react';
import { Component, createRef } from 'react';
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 SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { injectMuiTheme } from './injectMuiTheme';
import { GRID_COLUMNS, panelTilePaperSx } from './panelTileLayout';
import {
GRID_COLUMNS,
TILE_ACTION_PADDING,
panelTilePaperSx,
tileActionIconSx,
tileActionSx,
} from './panelTileLayout';
class PanelTileBase extends Component {
renderTitle() {
const { title } = this.props;
const { title, action } = this.props;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1 }}
sx={{ display: 'block', mb: 1, pr: action ? 9 : 0 }}
>
{title}
</Typography>
);
}
renderAction() {
const { action } = this.props;
if (!action) return null;
const { label, icon: Icon, onClick, busy } = action;
return (
<>
<IconButton
disabled
aria-hidden
tabIndex={-1}
sx={{
...tileActionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<SaveOutlinedIcon sx={tileActionIconSx} />
</IconButton>
<IconButton
type="button"
onClick={onClick}
aria-label={label}
disabled={busy}
sx={{ ...tileActionSx, right: 4 }}
>
{busy ? (
<CircularProgress size={22} color="inherit" />
) : (
<Icon sx={tileActionIconSx} />
)}
</IconButton>
</>
);
}
render() {
const { theme, wide = 1, children, sx, minHeight = 120, tileKey } = this.props;
const { theme, wide = 1, children, sx, minHeight = 120, tileKey, action } = this.props;
const actionPadding = action ? TILE_ACTION_PADDING : 0;
return (
<Paper
@@ -34,8 +82,9 @@ class PanelTileBase extends Component {
theme,
})}
>
{this.renderAction()}
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
<Box sx={{ flex: 1, minWidth: 0, pr: actionPadding }}>{children}</Box>
</Paper>
);
}

View File

@@ -17,6 +17,17 @@ export function responsiveColumnSpan(wide) {
}
export const PENDING_EDIT_RING_PX = 4;
export const TILE_ACTION_PADDING = 12;
export const tileActionSx = {
position: 'absolute',
top: 4,
color: 'text.secondary',
};
export const tileActionIconSx = {
fontSize: 28,
};
export function panelTileBackground(theme, tileKey) {
if (!tileKey || !theme?.panelTiles) return undefined;