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

@@ -385,6 +385,27 @@ export const category = {
body: { pos },
});
},
/**
* POST /api/category/:id/image
* Body: raw AVIF bytes (Content-Type: image/avif)
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
uploadImage(categoryId, file) {
return httpBinary(`/api/category/${categoryId}/image`, {
method: 'POST',
contentType: 'image/avif',
body: file,
});
},
/**
* DELETE /api/category/:id/image
* → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
deleteImage(categoryId) {
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
},
};
/**

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>
);
}

View File

@@ -48,6 +48,8 @@ import {
selectChildCategories,
selectRenameMode,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
} from '../../store/categorySlice';
import {
enableTerminalCategory,
@@ -375,6 +377,33 @@ class ArtikelKategorienPage extends Component {
}}
onOpen={() => this.navigateToCategoryById(item.id)}
hasTerminals={hasTerminals}
categoryFullHash={item.fullHash}
thumbHash={item.thumbHash}
onPhotoUpload={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
// Prepare image (resize and encode to AVIF)
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
const avifFile = await prepareArticlePhoto(file);
await this.props.uploadCategoryImage({
categoryId: item.id,
file: avifFile,
});
} catch (err) {
notifyError(err);
}
};
input.click();
}}
onPhotoDelete={() => {
void this.props.deleteCategoryImage(item.id)
.catch(notifyError);
}}
nameInput={
<TextField
value={draftName}
@@ -417,6 +446,8 @@ class ArtikelKategorienPage extends Component {
key={item.id}
label={label}
onClick={() => this.navigateToCategoryById(item.id)}
thumbHash={item.thumbHash}
fullHash={item.fullHash}
/>
);
})}
@@ -480,6 +511,8 @@ const mapDispatchToProps = {
enableTerminalCategory,
disableTerminalCategory,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
};
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -238,6 +238,38 @@ export const toggleCategoryPos = createAsyncThunk(
},
);
/**
* POST /api/category/:id/image → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
export const uploadCategoryImage = createAsyncThunk(
'category/uploadImage',
async ({ categoryId, file }, { rejectWithValue }) => {
try {
const data = await category.uploadImage(categoryId, file);
requireObject(data.category, 'POST /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* DELETE /api/category/:id/image → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
export const deleteCategoryImage = createAsyncThunk(
'category/deleteImage',
async (categoryId, { rejectWithValue }) => {
try {
const data = await category.deleteImage(categoryId);
requireObject(data.category, 'DELETE /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
languageId: null,
@@ -442,6 +474,30 @@ const categorySlice = createSlice({
state.categories[key] = { ...existing, pos: updated.pos };
}
})
.addCase(uploadCategoryImage.fulfilled, (state, action) => {
const { id, fullHash, thumbW, thumbH, thumbHash } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, fullHash, thumbW, thumbH, thumbHash };
}
})
.addCase(deleteCategoryImage.fulfilled, (state, action) => {
const { id } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = {
...existing,
fullHash: null,
thumbW: null,
thumbH: null,
thumbHash: null,
};
}
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {