Files
dashfront/src/components/tiles/CategoryTile.jsx
2026-10-05 01:01:03 +02:00

468 lines
13 KiB
JavaScript

import { Component, PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Switch from '@mui/material/Switch';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
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, isImageLoaded, markImageLoaded } from '../article/ThumbHashImage';
import { thumb } from '../../api';
import { tileBaseSx } from 'dashgui/tiles';
import {
RED_TINT,
SCRIM_BADGE,
SCRIM_MEDIUM,
VEIL_HOVER,
} from '../../colors';
/**
* Top row (1/3): sort handle | settings toggle | move handle.
* Icons are pinned to exact thirds (left / center / right) so they stay
* evenly spread regardless of icon size or button padding.
*/
function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick }) {
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
const isPhotoMode = settingsTab === -2;
const isDeleteMode = settingsTab === -3;
const handleSx = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
color: 'common.white',
'&:hover': { bgcolor: VEIL_HOVER },
};
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '33.33%',
bgcolor: SCRIM_MEDIUM,
}}
>
<IconButton
className="sortable-handle"
aria-label="Verschieben"
disableRipple
sx={{
...handleSx,
left: 0,
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
}}
>
<DragHandleIcon sx={{ fontSize: 36 }} />
</IconButton>
<IconButton
aria-label="Einstellungen"
disableRipple
onClick={onSettingsClick}
sx={{
...handleSx,
left: '50%',
transform: 'translate(-50%, -50%)',
color: isDeleteMode
? 'error.main'
: isTerminalMode || isPhotoMode
? 'primary.main'
: 'common.white',
}}
>
{isTerminalMode ? (
<PointOfSaleIcon sx={{ fontSize: 28 }} />
) : isPhotoMode ? (
<PhotoCameraIcon sx={{ fontSize: 28 }} />
) : isDeleteMode ? (
<DeleteOutlinedIcon sx={{ fontSize: 28 }} />
) : (
<SettingsIcon sx={{ fontSize: 28 }} />
)}
</IconButton>
<IconButton
aria-label="Kategorie verschieben"
disableRipple
onClick={(e) => {
e.stopPropagation();
onPlacementClick?.();
}}
sx={{
...handleSx,
right: 0,
cursor: 'pointer',
}}
>
<AccountTreeIcon sx={{ fontSize: 36 }} />
</IconButton>
</Box>
);
}
/**
* Middle row (1/3): gray band — TextField or terminal toggle.
*/
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, fullHash }) {
const isActionTab = settingsTab === -2 || settingsTab === -3;
const fillSx = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
...(isActionTab
? {
'&:hover': {
bgcolor: settingsTab === -3 ? RED_TINT : 'grey.400',
},
}
: {}),
};
// Show terminal-specific toggle when on a terminal tab
if (settingsTab >= 0 && settingsTab < terminals.length) {
const terminal = terminals[settingsTab];
const isEnabled = categoryPosterminalIds.includes(terminal.id);
return (
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 80, color: 'text.secondary' }}
>
{terminal.name}
</Typography>
<Switch
checked={isEnabled}
onChange={(e) => onTerminalToggle?.(terminal.id, e.target.checked)}
size="small"
/>
</Box>
</Box>
);
}
// Show photo upload UI when on photo tab
if (settingsTab === -2) {
return (
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhotoCameraIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 100, color: 'text.secondary' }}
>
{fullHash ? 'Bild löschen' : 'Hochladen'}
</Typography>
</Box>
</Box>
);
}
// Show category deletion UI when on delete tab
if (settingsTab === -3) {
return (
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DeleteOutlinedIcon sx={{ fontSize: 20, color: 'error.main' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 120, color: 'error.main', fontWeight: 600 }}
>
Kategorie löschen
</Typography>
</Box>
</Box>
);
}
return (
<Box sx={fillSx}>
{nameInput}
</Box>
);
}
/**
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
*/
class CategoryBackdrop extends PureComponent {
constructor(props) {
super(props);
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
this.isCached = isImageLoaded(props.fullHash);
}
componentDidUpdate(prevProps) {
if (prevProps.thumbHash !== this.props.thumbHash) {
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
}
if (prevProps.fullHash !== this.props.fullHash) {
this.isCached = isImageLoaded(this.props.fullHash);
this.forceUpdate();
}
}
handleLoad = (e) => {
if (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 || isImageLoaded(fullHash);
return (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
{!isCached && this.thumbUrl ? (
<Box
component="img"
src={this.thumbUrl}
alt=""
aria-hidden
sx={{
position: 'absolute',
inset: 0,
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : null}
{fullHash ? (
<Box
component="img"
src={thumb.url(fullHash)}
alt=""
onLoad={this.handleLoad}
sx={{
position: 'absolute',
inset: 0,
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : null}
</Box>
);
}
}
/**
* Category tile.
*
* Normal mode (edit=false): clickable tile with label overlay; click navigates.
* Edit mode (edit=true): three rows of 1/3 each —
* Top row: sort-handle | settings toggle | drop-handle
* Middle row: gray band — TextField, POS toggle, or photo upload
* Bottom row: upper half = open-category click zone
* lower half = gray band with chevrons + delete
*/
export class CategoryTile extends Component {
state = { settingsTab: -1 }; // -1 = rename, -2 = photo, -3 = delete, 0..n-1 = terminal index
handleClick = () => {
this.props.onClick?.();
};
handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleClick();
}
};
handleSettingsToggle = () => {
const { terminals } = this.props;
const terminalCount = terminals?.length || 0;
this.setState((prev) => {
let nextTab;
if (prev.settingsTab === -1) {
nextTab = terminalCount > 0 ? 0 : -2;
} else if (prev.settingsTab >= 0 && prev.settingsTab < terminalCount - 1) {
nextTab = prev.settingsTab + 1;
} else if (prev.settingsTab === terminalCount - 1 || prev.settingsTab >= 0) {
nextTab = -2;
} else if (prev.settingsTab === -2) {
nextTab = -3;
} else {
nextTab = -1;
}
return { settingsTab: nextTab };
});
};
handleMiddleBandClick = () => {
const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, fullHash, onDelete } = this.props;
if (settingsTab === -2) {
if (fullHash) {
onPhotoDelete?.();
} else {
onPhotoUpload?.();
}
} else if (settingsTab === -3) {
onDelete?.();
}
};
renderLabel() {
const { label, nameInput } = this.props;
if (label == null) return null;
return (
<Box
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: SCRIM_BADGE,
px: 1,
}}
>
{nameInput ?? (
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{ lineHeight: 1.2, color: 'common.white' }}
>
{label}
</Typography>
)}
</Box>
);
}
render() {
const {
sx,
edit,
label,
thumbHash,
fullHash,
onClick,
nameInput,
terminals,
categoryPosterminalIds,
onTerminalToggle,
onPhotoUpload,
onPhotoDelete,
onPlacementClick,
onOpen,
onDelete,
...rest
} = this.props;
const { settingsTab } = this.state;
// Shared by both modes: the thumbnail backdrop.
const backdrop = (
<CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
);
// Only the overlay differs between normal and edit mode.
const overlay = edit ? (
<>
<TopBand
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
onPlacementClick={onPlacementClick}
/>
<Box
onClick={this.handleMiddleBandClick}
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
cursor: settingsTab === -2 || settingsTab === -3 ? 'pointer' : 'default',
}}
>
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
fullHash={fullHash}
/>
</Box>
{/* Lower third: clickable in edit mode to open/navigate into category */}
<Box
role="button"
tabIndex={0}
aria-label="Kategorie öffnen"
onClick={onOpen || onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(onOpen || onClick)?.();
}
}}
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '33.33%',
cursor: 'pointer',
'&:hover': {
bgcolor: 'action.hover',
},
}}
/>
</>
) : (
this.renderLabel()
);
return (
<Box
{...rest}
role={edit ? undefined : 'button'}
tabIndex={edit ? undefined : 0}
onClick={edit ? undefined : this.handleClick}
onKeyDown={edit ? undefined : this.handleKeyDown}
sx={{
...tileBaseSx,
position: 'relative',
overflow: 'hidden',
...(edit
? { cursor: 'default', '&:hover': { bgcolor: 'background.paper', boxShadow: 0 } }
: { cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }),
...sx,
}}
>
{backdrop}
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{overlay}
</Box>
</Box>
);
}
}