468 lines
13 KiB
JavaScript
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>
|
|
);
|
|
}
|
|
}
|