Add category image upload with thumbnail backdrop in category tiles

- Add category image API methods (upload/delete) to src/api/index.js
- Add uploadCategoryImage/deleteCategoryImage thunks and extraReducers in categorySlice
- Add CategoryBackdrop component showing ThumbHash placeholder with full thumb overlay
- Add photo upload/delete tab to CategoryEditTile settings cycle
- Category tile label band uses semi-transparent black overlay with white text
- Pass thumbHash/fullHash to both CategoryTile and CategoryEditTile
This commit is contained in:
seb
2026-08-03 07:57:44 +02:00
parent 4bbe9caf55
commit 404a7f4fce
4 changed files with 257 additions and 58 deletions

View File

@@ -10,14 +10,17 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import ThumbHashImage from '../article/ThumbHashImage';
import { thumb } from '../../api';
import { tileBaseSx } from './Tile';
/**
* Top row (1/3): sort handle | settings toggle | move handle.
*/
function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
// settingsTab >= 0 means we're on a terminal tab
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
const isPhotoMode = settingsTab === -2;
return (
<Box
sx={{
@@ -45,11 +48,13 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
disableRipple
onClick={onSettingsClick}
sx={{
color: isTerminalMode ? 'primary.main' : 'action.active',
color: isTerminalMode || isPhotoMode ? 'primary.main' : 'action.active',
}}
>
{isTerminalMode ? (
<PointOfSaleIcon sx={{ fontSize: 28 }} />
) : isPhotoMode ? (
<PhotoCameraIcon sx={{ fontSize: 28 }} />
) : (
<SettingsIcon sx={{ fontSize: 28 }} />
)}
@@ -69,27 +74,23 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
/**
* Middle row (1/3): gray band — TextField or terminal toggle.
*/
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) {
console.log('[MiddleBand] settingsTab:', settingsTab, 'terminals:', terminals?.length, 'categoryPosterminalIds:', categoryPosterminalIds);
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, onPhotoUpload, onPhotoDelete, categoryFullHash }) {
const fillSx = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
};
// 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={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
}}
>
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography
@@ -109,21 +110,26 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
);
}
// 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: 80, color: 'text.secondary' }}
>
{categoryFullHash ? 'Löschen' : 'Hochladen'}
</Typography>
</Box>
</Box>
);
}
return (
<Box
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
}}
>
<Box sx={fillSx}>
{nameInput}
</Box>
);
@@ -207,6 +213,57 @@ function BottomRow({ onOpen, editBand }) {
);
}
/**
* 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 Component {
state = { thumbLoaded: false };
componentDidUpdate(prevProps) {
if (prevProps.fullHash !== this.props.fullHash) {
this.setState({ thumbLoaded: false });
}
}
handleThumbLoad = () => {
this.setState({ thumbLoaded: true });
};
handleThumbError = () => {
this.setState({ thumbLoaded: false });
};
render() {
const { thumbHash, fullHash } = this.props;
const { thumbLoaded } = this.state;
if (!thumbHash && !fullHash) return null;
return (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
{thumbHash && !thumbLoaded ? <ThumbHashImage thumbHash={thumbHash} /> : null}
{fullHash ? (
<Box
component="img"
src={thumb.url(fullHash)}
alt=""
onLoad={this.handleThumbLoad}
onError={this.handleThumbError}
sx={{
position: 'absolute',
inset: 0,
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: thumbLoaded ? 1 : 0,
}}
/>
) : null}
</Box>
);
}
}
/**
* Category tile (normal mode): no icon, click navigates to the category.
*/
@@ -229,7 +286,7 @@ export class CategoryTile extends Component {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
bgcolor: 'rgba(0, 0, 0, 0.5)',
px: 1,
}}
>
@@ -238,7 +295,7 @@ export class CategoryTile extends Component {
variant="body2"
fontWeight={600}
noWrap
sx={{ lineHeight: 1.2, color: 'text.primary' }}
sx={{ lineHeight: 1.2, color: 'common.white' }}
>
{label}
</Typography>
@@ -248,7 +305,7 @@ export class CategoryTile extends Component {
}
render() {
const { sx, onClick: _onClick, label, ...rest } = this.props;
const { sx, onClick: _onClick, label, thumbHash, fullHash, ...rest } = this.props;
return (
<Box
component="button"
@@ -258,10 +315,14 @@ export class CategoryTile extends Component {
sx={{
...tileBaseSx,
position: 'relative',
overflow: 'hidden',
...sx,
}}
>
{this.renderLabel()}
<CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{this.renderLabel()}
</Box>
</Box>
);
}
@@ -271,29 +332,36 @@ export class CategoryTile extends Component {
* Category tile for edit mode: three rows of 1/3 each.
*
* Top row: sort-handle | settings toggle | drop-handle
* Middle row: gray band — TextField or POS toggle
* 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 CategoryEditTile extends Component {
state = { settingsTab: -1 }; // -1 = rename, 0..n-1 = terminal index
state = { settingsTab: -1 }; // -1 = rename, -2 = photo, 0..n-1 = terminal index
handleSettingsToggle = () => {
const { terminals } = this.props;
const terminalCount = terminals?.length || 0;
this.setState((prev) => {
const next = prev.settingsTab + 1;
const nextTab = next >= terminalCount ? -1 : next;
console.log('[CategoryEditTile] settings toggle', {
terminals: terminals?.length,
terminalCount,
from: prev.settingsTab,
to: nextTab,
});
// Cycle: photo (-2) → rename (-1) → terminal 0 → ... → terminal n-1 → photo (-2)
const nextTab = next >= terminalCount ? -2 : next;
return { settingsTab: nextTab };
});
};
handleMiddleBandClick = () => {
const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, categoryFullHash } = this.props;
if (settingsTab === -2) {
if (categoryFullHash) {
onPhotoDelete?.();
} else {
onPhotoUpload?.();
}
}
};
render() {
const {
label,
@@ -304,6 +372,10 @@ export class CategoryEditTile extends Component {
categoryPosterminalIds,
onTerminalToggle,
hasTerminals: _hasTerminals,
categoryFullHash,
thumbHash,
onPhotoUpload: _onPhotoUpload,
onPhotoDelete: _onPhotoDelete,
sx,
...rest
} = this.props;
@@ -316,23 +388,40 @@ export class CategoryEditTile extends Component {
...tileBaseSx,
cursor: 'default',
position: 'relative',
overflow: 'hidden',
'&:hover': { bgcolor: 'background.paper', boxShadow: 0 },
...sx,
}}
>
<TopBand
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
/>
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
/>
<BottomRow onOpen={onOpen} editBand={editBand} />
<CategoryBackdrop thumbHash={thumbHash} fullHash={categoryFullHash} />
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<TopBand
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
/>
<Box
onClick={this.handleMiddleBandClick}
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
cursor: settingsTab === -2 ? 'pointer' : 'default',
}}
>
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
categoryFullHash={categoryFullHash}
/>
</Box>
<BottomRow onOpen={onOpen} editBand={editBand} />
</Box>
</Box>
);
}