This commit is contained in:
seb
2026-10-04 09:37:09 +02:00
parent da318223c1
commit dd8566eabc
3 changed files with 284 additions and 281 deletions

View File

@@ -3,10 +3,15 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { Tile, tileBaseSx } from 'dashgui/tiles';
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price';
import { articleSku } from '../article/articleRoute';
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
import {
getThumbHashDataUrl,
getCachedImage,
isImageLoaded,
markImageLoaded,
} from '../article/ThumbHashImage';
import { BrandLabel } from '../brand';
import { thumb } from '../../api';
@@ -103,7 +108,26 @@ class ArticleTileBackdrop extends PureComponent {
constructor(props) {
super(props);
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
this.img = getCachedImage(props.fullHash);
const fullHash = props.fullHash;
if (fullHash) {
const cached = getCachedImage(fullHash);
if (cached) {
this.img = cached;
} else {
const img = new Image();
img.onload = () => {
markImageLoaded(fullHash, img);
if (this.img === img) {
this.paint();
}
};
img.src = thumb.url(fullHash);
this.img = img;
if (img.complete && img.naturalWidth) {
markImageLoaded(fullHash, img);
}
}
}
}
componentDidMount() {
@@ -127,35 +151,37 @@ class ArticleTileBackdrop extends PureComponent {
loadImage() {
const { fullHash } = this.props;
if (fullHash) {
const cached = getCachedImage(fullHash);
if (cached) {
this.img = cached;
if (!fullHash) return;
const cached = getCachedImage(fullHash);
if (cached) {
this.img = cached;
this.paint();
return;
}
if (this.img && this.img.src === thumb.url(fullHash)) {
if (this.img.complete && this.img.naturalWidth) {
markImageLoaded(fullHash, this.img);
this.paint();
return;
}
const img = new Image();
img.onload = () => {
imageCache.set(fullHash, img);
if (this.img === img) {
this.paint();
}
};
img.src = thumb.url(fullHash);
this.img = img;
if (img.complete && img.naturalWidth) {
imageCache.set(fullHash, img);
return;
}
const img = new Image();
img.onload = () => {
markImageLoaded(fullHash, img);
if (this.img === img) {
this.paint();
return;
}
};
img.src = thumb.url(fullHash);
this.img = img;
if (img.complete && img.naturalWidth) {
markImageLoaded(fullHash, img);
this.paint();
}
}
setCanvas = (canvas) => {
this.canvasRef = canvas;
if (!this.img && this.props.fullHash) {
this.img = getCachedImage(this.props.fullHash);
}
if (canvas && typeof ResizeObserver !== 'undefined') {
this.observer?.disconnect();
this.observer = new ResizeObserver(() => this.paint());
@@ -168,16 +194,18 @@ class ArticleTileBackdrop extends PureComponent {
const canvas = this.canvasRef;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dstW = Math.round(rect.width);
const dstH = Math.round(rect.height);
const dstW = Math.round(rect.width) || canvas.clientWidth || TILE_SIZE;
const dstH = Math.round(rect.height) || canvas.clientHeight || TILE_SIZE;
if (!dstW || !dstH) return;
canvas.width = dstW;
canvas.height = dstH;
if (canvas.width !== dstW) canvas.width = dstW;
if (canvas.height !== dstH) canvas.height = dstH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = this.img || getCachedImage(this.props.fullHash);
const img = (this.img && this.img.complete && this.img.naturalWidth)
? this.img
: getCachedImage(this.props.fullHash);
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
this.img = img;
drawContainedWithStretchedEdges(
@@ -192,7 +220,8 @@ class ArticleTileBackdrop extends PureComponent {
};
render() {
const hasCachedImage = Boolean(this.img && this.img.complete && this.img.naturalWidth);
const { fullHash } = this.props;
const showThumb = !isImageLoaded(fullHash) && Boolean(this.thumbUrl);
return (
<Box
sx={{
@@ -203,7 +232,7 @@ class ArticleTileBackdrop extends PureComponent {
overflow: 'hidden',
}}
>
{!hasCachedImage && this.thumbUrl ? (
{showThumb ? (
<Box
component="img"
src={this.thumbUrl}

View File

@@ -9,7 +9,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
import { getThumbHashDataUrl, isImageLoaded, markImageLoaded } from '../article/ThumbHashImage';
import { thumb } from '../../api';
import { tileBaseSx } from 'dashgui/tiles';
@@ -198,7 +198,7 @@ class CategoryBackdrop extends PureComponent {
constructor(props) {
super(props);
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
this.isCached = Boolean(getCachedImage(props.fullHash));
this.isCached = isImageLoaded(props.fullHash);
}
componentDidUpdate(prevProps) {
@@ -206,21 +206,21 @@ class CategoryBackdrop extends PureComponent {
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
}
if (prevProps.fullHash !== this.props.fullHash) {
this.isCached = Boolean(getCachedImage(this.props.fullHash));
this.isCached = isImageLoaded(this.props.fullHash);
this.forceUpdate();
}
}
handleLoad = (e) => {
if (this.props.fullHash && e.target) {
imageCache.set(this.props.fullHash, e.target);
markImageLoaded(this.props.fullHash, e.target);
}
};
render() {
const { fullHash } = this.props;
if (!this.thumbUrl && !fullHash) return null;
const isCached = this.isCached || Boolean(getCachedImage(fullHash));
const isCached = this.isCached || isImageLoaded(fullHash);
return (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
{!isCached && this.thumbUrl ? (

View File

@@ -630,36 +630,44 @@ class ArtikelMerkmalePage extends PureComponent {
`}</style>
<PanelTileGrid>
{/* Tile 1: Merkmale (Freitext) */}
<PanelTile wide={2} minHeight={280} color="#fed7aa">
{/* Tile 1: Attribute (Auswahllisten) */}
<PanelTile wide={2} minHeight={280} color="#bfdbfe">
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<StyleIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Merkmale
Auswahl-Merkmale
</Typography>
<Chip label={properties.length} size="small" sx={{ ml: 'auto', fontWeight: 600, height: 20 }} />
<Chip label={attributes.length} size="small" sx={{ ml: 'auto', fontWeight: 600, height: 20 }} />
</Box>
<Typography variant="body2" color="text.secondary">
Reihenfolge per Drag &amp; Drop anpassen.
</Typography>
<List dense disablePadding ref={this.propListRef}>
{properties.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Noch keine Merkmale angelegt.
</Typography>
) : (
properties.map((p) => (
<List dense disablePadding ref={this.attrListRef}>
{attributes.map((a) => {
const isSelected = selectedAttr?.id === a.id;
return (
<ListItem
key={p.id}
key={a.id}
disableGutters
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
data-id={p.id}
onClick={() => this.handleSelectAttribute(a.id)}
sx={{
gap: 1,
py: 0.75,
px: 0.5,
borderRadius: 1.5,
alignItems: 'center',
cursor: 'pointer',
bgcolor: isSelected ? 'rgba(59, 130, 246, 0.12)' : 'transparent',
border: isSelected ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid transparent',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isSelected ? 'rgba(59, 130, 246, 0.16)' : 'rgba(0, 0, 0, 0.04)',
},
}}
data-id={a.id}
>
<Box
className="drag-handle"
onClick={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
@@ -671,46 +679,57 @@ class ArtikelMerkmalePage extends PureComponent {
>
<DragIndicatorIcon fontSize="small" />
</Box>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<SingleLineTextEdit
size="small"
label=""
value={propDrafts[p.id] ?? ''}
disabled={busy}
onChange={(next) =>
this.setState((prev) => ({
propDrafts: { ...prev.propDrafts, [p.id]: next },
}))
}
onBlur={() => void this.handleRenameProperty(p)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState((prev) => ({
propDrafts: { ...prev.propDrafts, [p.id]: p.name },
}));
event.target.blur();
}
<StyleIcon
sx={{
color: isSelected ? 'primary.main' : 'text.secondary',
fontSize: 20,
flexShrink: 0,
}}
sx={{ flex: 1 }}
/>
<Box onClick={(e) => e.stopPropagation()} sx={{ flex: 1, minWidth: 0 }}>
<SingleLineTextEdit
size="small"
label=""
value={attrDrafts[a.id] ?? ''}
disabled={busy}
onChange={(next) =>
this.setState((prev) => ({
attrDrafts: { ...prev.attrDrafts, [a.id]: next },
}))
}
onBlur={() => void this.handleRenameAttribute(a)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState((prev) => ({
attrDrafts: { ...prev.attrDrafts, [a.id]: a.name },
}));
event.target.blur();
}
}}
sx={{ width: '100%' }}
/>
</Box>
<Tooltip
title={
p.exclusive
? 'Einzelauswahl (Artikel kann max. 1 Wert für dieses Merkmal haben)'
: 'Mehrfachauswahl (Artikel kann mehrere Werte für dieses Merkmal haben)'
a.exclusive
? 'Einzelauswahl (Artikel kann max. 1 Wert wählen)'
: 'Mehrfachauswahl (Artikel kann mehrere Werte wählen)'
}
>
<Chip
label={p.exclusive ? '1x' : 'Multi'}
label={a.exclusive ? '1x' : 'Multi'}
size="small"
color={p.exclusive ? 'primary' : 'default'}
variant={p.exclusive ? 'filled' : 'outlined'}
onClick={(e) => void this.handleTogglePropertyExclusive(p, e)}
color={a.exclusive ? 'primary' : 'default'}
variant={a.exclusive ? 'filled' : 'outlined'}
onClick={(e) => void this.handleToggleAttributeExclusive(a, e)}
sx={{
height: 22,
fontSize: '0.72rem',
@@ -720,206 +739,27 @@ class ArtikelMerkmalePage extends PureComponent {
}}
/>
</Tooltip>
<IconButton
edge="end"
size="small"
aria-label={`„${p.name}" löschen`}
aria-label={`„${a.name}" löschen`}
disabled={busy}
onClick={() => void this.handleDeleteProperty(p)}
onClick={(e) => void this.handleDeleteAttribute(a, e)}
sx={{ flexShrink: 0 }}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItem>
))
)}
);
})}
</List>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
size="small"
label="Neues Merkmal"
value={newPropName}
disabled={busy}
onChange={(next) => this.setState({ newPropName: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateProperty();
}
}}
sx={{ flex: 1 }}
/>
<Button
variant="contained"
size="small"
disabled={busy || !newPropName.trim()}
onClick={() => void this.handleCreateProperty()}
>
{busyKey === 'new-prop' ? '…' : 'Hinzufügen'}
</Button>
</Box>
<FormControlLabel
control={
<Checkbox
size="small"
checked={newPropExclusive}
disabled={busy}
onChange={(e) => this.setState({ newPropExclusive: e.target.checked })}
sx={{ p: 0.5 }}
/>
}
label={
<Typography variant="caption" color="text.secondary">
Einzelauswahl (max. 1 Wert pro Artikel)
</Typography>
}
sx={{ ml: 0.5, mt: -0.5 }}
/>
</Box>
</Stack>
</PanelTile>
{/* Tile 2: Attribute (Auswahllisten) */}
<PanelTile wide={2} minHeight={280} color="#bfdbfe">
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StyleIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Attribute
</Typography>
<Chip label={attributes.length} size="small" sx={{ ml: 'auto', fontWeight: 600, height: 20 }} />
</Box>
<Typography variant="body2" color="text.secondary">
Reihenfolge per Drag &amp; Drop anpassen.
</Typography>
<List dense disablePadding ref={this.attrListRef}>
{attributes.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Noch keine Attribute angelegt.
</Typography>
) : (
attributes.map((a) => {
const isSelected = selectedAttr?.id === a.id;
return (
<ListItem
key={a.id}
disableGutters
onClick={() => this.handleSelectAttribute(a.id)}
sx={{
gap: 1,
py: 0.75,
px: 0.5,
borderRadius: 1.5,
alignItems: 'center',
cursor: 'pointer',
bgcolor: isSelected ? 'rgba(59, 130, 246, 0.12)' : 'transparent',
border: isSelected ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid transparent',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isSelected ? 'rgba(59, 130, 246, 0.16)' : 'rgba(0, 0, 0, 0.04)',
},
}}
data-id={a.id}
>
<Box
className="drag-handle"
onClick={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
color: 'text.disabled',
flexShrink: 0,
'&:hover': { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon fontSize="small" />
</Box>
<StyleIcon
sx={{
color: isSelected ? 'primary.main' : 'text.secondary',
fontSize: 20,
flexShrink: 0,
}}
/>
<Box onClick={(e) => e.stopPropagation()} sx={{ flex: 1, minWidth: 0 }}>
<SingleLineTextEdit
size="small"
label=""
value={attrDrafts[a.id] ?? ''}
disabled={busy}
onChange={(next) =>
this.setState((prev) => ({
attrDrafts: { ...prev.attrDrafts, [a.id]: next },
}))
}
onBlur={() => void this.handleRenameAttribute(a)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState((prev) => ({
attrDrafts: { ...prev.attrDrafts, [a.id]: a.name },
}));
event.target.blur();
}
}}
sx={{ width: '100%' }}
/>
</Box>
<Tooltip
title={
a.exclusive
? 'Einzelauswahl (Artikel kann max. 1 Wert wählen)'
: 'Mehrfachauswahl (Artikel kann mehrere Werte wählen)'
}
>
<Chip
label={a.exclusive ? '1x' : 'Multi'}
size="small"
color={a.exclusive ? 'primary' : 'default'}
variant={a.exclusive ? 'filled' : 'outlined'}
onClick={(e) => void this.handleToggleAttributeExclusive(a, e)}
sx={{
height: 22,
fontSize: '0.72rem',
fontWeight: 600,
cursor: 'pointer',
flexShrink: 0,
}}
/>
</Tooltip>
<IconButton
edge="end"
size="small"
aria-label={`„${a.name}" löschen`}
disabled={busy}
onClick={(e) => void this.handleDeleteAttribute(a, e)}
sx={{ flexShrink: 0 }}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItem>
);
})
)}
</List>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
size="small"
label="Neues Attribut"
label=""
value={newAttrName}
disabled={busy}
onChange={(next) => this.setState({ newAttrName: next })}
@@ -961,7 +801,7 @@ class ArtikelMerkmalePage extends PureComponent {
</Stack>
</PanelTile>
{/* Tile 3: Werte für das gewählte Attribut */}
{/* Tile 2: Werte für das gewählte Attribut */}
<PanelTile wide={2} minHeight={280} color="#ddd6fe">
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -978,14 +818,8 @@ class ArtikelMerkmalePage extends PureComponent {
) : null}
</Box>
<Typography variant="body2" color="text.secondary">
{selectedAttr
? 'Reihenfolge per Drag & Drop anpassen.'
: 'Wähle links ein Attribut aus, um Werte zu bearbeiten.'}
</Typography>
{!selectedAttr ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 3, textAlign: 'center', fontStyle: 'italic' }}>
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Kein Attribut ausgewählt.
</Typography>
) : (
@@ -1060,7 +894,7 @@ class ArtikelMerkmalePage extends PureComponent {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
size="small"
label={`Neuer Wert für „${selectedAttr.name}“`}
label=""
value={newValText}
disabled={busy}
onChange={(next) => this.setState({ newValText: next })}
@@ -1085,6 +919,146 @@ class ArtikelMerkmalePage extends PureComponent {
)}
</Stack>
</PanelTile>
{/* Tile 3: Merkmale (Freitext) */}
<PanelTile wide={2} minHeight={280} color="#fed7aa">
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Text-Merkmale
</Typography>
<Chip label={properties.length} size="small" sx={{ ml: 'auto', fontWeight: 600, height: 20 }} />
</Box>
<List dense disablePadding ref={this.propListRef}>
{properties.map((p) => (
<ListItem
key={p.id}
disableGutters
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
data-id={p.id}
>
<Box
className="drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
color: 'text.disabled',
flexShrink: 0,
'&:hover': { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon fontSize="small" />
</Box>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<SingleLineTextEdit
size="small"
label=""
value={propDrafts[p.id] ?? ''}
disabled={busy}
onChange={(next) =>
this.setState((prev) => ({
propDrafts: { ...prev.propDrafts, [p.id]: next },
}))
}
onBlur={() => void this.handleRenameProperty(p)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState((prev) => ({
propDrafts: { ...prev.propDrafts, [p.id]: p.name },
}));
event.target.blur();
}
}}
sx={{ flex: 1 }}
/>
<Tooltip
title={
p.exclusive
? 'Einzelauswahl (Artikel kann max. 1 Wert für dieses Merkmal haben)'
: 'Mehrfachauswahl (Artikel kann mehrere Werte für dieses Merkmal haben)'
}
>
<Chip
label={p.exclusive ? '1x' : 'Multi'}
size="small"
color={p.exclusive ? 'primary' : 'default'}
variant={p.exclusive ? 'filled' : 'outlined'}
onClick={(e) => void this.handleTogglePropertyExclusive(p, e)}
sx={{
height: 22,
fontSize: '0.72rem',
fontWeight: 600,
cursor: 'pointer',
flexShrink: 0,
}}
/>
</Tooltip>
<IconButton
edge="end"
size="small"
aria-label={`„${p.name}" löschen`}
disabled={busy}
onClick={() => void this.handleDeleteProperty(p)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
size="small"
label=""
value={newPropName}
disabled={busy}
onChange={(next) => this.setState({ newPropName: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateProperty();
}
}}
sx={{ flex: 1 }}
/>
<Button
variant="contained"
size="small"
disabled={busy || !newPropName.trim()}
onClick={() => void this.handleCreateProperty()}
>
{busyKey === 'new-prop' ? '…' : 'Hinzufügen'}
</Button>
</Box>
<FormControlLabel
control={
<Checkbox
size="small"
checked={newPropExclusive}
disabled={busy}
onChange={(e) => this.setState({ newPropExclusive: e.target.checked })}
sx={{ p: 0.5 }}
/>
}
label={
<Typography variant="caption" color="text.secondary">
Einzelauswahl (max. 1 Wert pro Artikel)
</Typography>
}
sx={{ ml: 0.5, mt: -0.5 }}
/>
</Box>
</Stack>
</PanelTile>
</PanelTileGrid>
</>
);