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 }, 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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; 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'; import { tileBaseSx } from './Tile';
/** /**
* Top row (1/3): sort handle | settings toggle | move handle. * Top row (1/3): sort handle | settings toggle | move handle.
*/ */
function TopBand({ onSettingsClick, settingsTab, terminalCount }) { function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
// settingsTab >= 0 means we're on a terminal tab
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount; const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
const isPhotoMode = settingsTab === -2;
return ( return (
<Box <Box
sx={{ sx={{
@@ -45,11 +48,13 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
disableRipple disableRipple
onClick={onSettingsClick} onClick={onSettingsClick}
sx={{ sx={{
color: isTerminalMode ? 'primary.main' : 'action.active', color: isTerminalMode || isPhotoMode ? 'primary.main' : 'action.active',
}} }}
> >
{isTerminalMode ? ( {isTerminalMode ? (
<PointOfSaleIcon sx={{ fontSize: 28 }} /> <PointOfSaleIcon sx={{ fontSize: 28 }} />
) : isPhotoMode ? (
<PhotoCameraIcon sx={{ fontSize: 28 }} />
) : ( ) : (
<SettingsIcon 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. * Middle row (1/3): gray band — TextField or terminal toggle.
*/ */
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) { function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, onPhotoUpload, onPhotoDelete, categoryFullHash }) {
console.log('[MiddleBand] settingsTab:', settingsTab, 'terminals:', terminals?.length, 'categoryPosterminalIds:', categoryPosterminalIds); 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 // Show terminal-specific toggle when on a terminal tab
if (settingsTab >= 0 && settingsTab < terminals.length) { if (settingsTab >= 0 && settingsTab < terminals.length) {
const terminal = terminals[settingsTab]; const terminal = terminals[settingsTab];
const isEnabled = categoryPosterminalIds.includes(terminal.id); const isEnabled = categoryPosterminalIds.includes(terminal.id);
return ( return (
<Box <Box sx={fillSx}>
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={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} /> <PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography <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 ( return (
<Box <Box sx={fillSx}>
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
}}
>
{nameInput} {nameInput}
</Box> </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. * Category tile (normal mode): no icon, click navigates to the category.
*/ */
@@ -229,7 +286,7 @@ export class CategoryTile extends Component {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bgcolor: 'grey.300', bgcolor: 'rgba(0, 0, 0, 0.5)',
px: 1, px: 1,
}} }}
> >
@@ -238,7 +295,7 @@ export class CategoryTile extends Component {
variant="body2" variant="body2"
fontWeight={600} fontWeight={600}
noWrap noWrap
sx={{ lineHeight: 1.2, color: 'text.primary' }} sx={{ lineHeight: 1.2, color: 'common.white' }}
> >
{label} {label}
</Typography> </Typography>
@@ -248,7 +305,7 @@ export class CategoryTile extends Component {
} }
render() { render() {
const { sx, onClick: _onClick, label, ...rest } = this.props; const { sx, onClick: _onClick, label, thumbHash, fullHash, ...rest } = this.props;
return ( return (
<Box <Box
component="button" component="button"
@@ -258,10 +315,14 @@ export class CategoryTile extends Component {
sx={{ sx={{
...tileBaseSx, ...tileBaseSx,
position: 'relative', position: 'relative',
overflow: 'hidden',
...sx, ...sx,
}} }}
> >
{this.renderLabel()} <CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{this.renderLabel()}
</Box>
</Box> </Box>
); );
} }
@@ -271,29 +332,36 @@ export class CategoryTile extends Component {
* Category tile for edit mode: three rows of 1/3 each. * Category tile for edit mode: three rows of 1/3 each.
* *
* Top row: sort-handle | settings toggle | drop-handle * 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 * Bottom row: upper half = open-category click zone
* lower half = gray band with chevrons + delete * lower half = gray band with chevrons + delete
*/ */
export class CategoryEditTile extends Component { 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 = () => { handleSettingsToggle = () => {
const { terminals } = this.props; const { terminals } = this.props;
const terminalCount = terminals?.length || 0; const terminalCount = terminals?.length || 0;
this.setState((prev) => { this.setState((prev) => {
const next = prev.settingsTab + 1; const next = prev.settingsTab + 1;
const nextTab = next >= terminalCount ? -1 : next; // Cycle: photo (-2) → rename (-1) → terminal 0 → ... → terminal n-1 → photo (-2)
console.log('[CategoryEditTile] settings toggle', { const nextTab = next >= terminalCount ? -2 : next;
terminals: terminals?.length,
terminalCount,
from: prev.settingsTab,
to: nextTab,
});
return { settingsTab: nextTab }; return { settingsTab: nextTab };
}); });
}; };
handleMiddleBandClick = () => {
const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, categoryFullHash } = this.props;
if (settingsTab === -2) {
if (categoryFullHash) {
onPhotoDelete?.();
} else {
onPhotoUpload?.();
}
}
};
render() { render() {
const { const {
label, label,
@@ -304,6 +372,10 @@ export class CategoryEditTile extends Component {
categoryPosterminalIds, categoryPosterminalIds,
onTerminalToggle, onTerminalToggle,
hasTerminals: _hasTerminals, hasTerminals: _hasTerminals,
categoryFullHash,
thumbHash,
onPhotoUpload: _onPhotoUpload,
onPhotoDelete: _onPhotoDelete,
sx, sx,
...rest ...rest
} = this.props; } = this.props;
@@ -316,23 +388,40 @@ export class CategoryEditTile extends Component {
...tileBaseSx, ...tileBaseSx,
cursor: 'default', cursor: 'default',
position: 'relative', position: 'relative',
overflow: 'hidden',
'&:hover': { bgcolor: 'background.paper', boxShadow: 0 }, '&:hover': { bgcolor: 'background.paper', boxShadow: 0 },
...sx, ...sx,
}} }}
> >
<TopBand <CategoryBackdrop thumbHash={thumbHash} fullHash={categoryFullHash} />
onSettingsClick={this.handleSettingsToggle} <Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
settingsTab={settingsTab} <TopBand
terminalCount={terminals?.length || 0} onSettingsClick={this.handleSettingsToggle}
/> settingsTab={settingsTab}
<MiddleBand terminalCount={terminals?.length || 0}
settingsTab={settingsTab} />
nameInput={nameInput} <Box
terminals={terminals} onClick={this.handleMiddleBandClick}
categoryPosterminalIds={categoryPosterminalIds} sx={{
onTerminalToggle={onTerminalToggle} position: 'absolute',
/> top: '33.33%',
<BottomRow onOpen={onOpen} editBand={editBand} /> 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> </Box>
); );
} }

View File

@@ -48,6 +48,8 @@ import {
selectChildCategories, selectChildCategories,
selectRenameMode, selectRenameMode,
patchCategoryPosterminalIds, patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
} from '../../store/categorySlice'; } from '../../store/categorySlice';
import { import {
enableTerminalCategory, enableTerminalCategory,
@@ -375,6 +377,33 @@ class ArtikelKategorienPage extends Component {
}} }}
onOpen={() => this.navigateToCategoryById(item.id)} onOpen={() => this.navigateToCategoryById(item.id)}
hasTerminals={hasTerminals} 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={ nameInput={
<TextField <TextField
value={draftName} value={draftName}
@@ -417,6 +446,8 @@ class ArtikelKategorienPage extends Component {
key={item.id} key={item.id}
label={label} label={label}
onClick={() => this.navigateToCategoryById(item.id)} onClick={() => this.navigateToCategoryById(item.id)}
thumbHash={item.thumbHash}
fullHash={item.fullHash}
/> />
); );
})} })}
@@ -480,6 +511,8 @@ const mapDispatchToProps = {
enableTerminalCategory, enableTerminalCategory,
disableTerminalCategory, disableTerminalCategory,
patchCategoryPosterminalIds, patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
}; };
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); 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 = { const initialState = {
companyId: null, companyId: null,
languageId: null, languageId: null,
@@ -442,6 +474,30 @@ const categorySlice = createSlice({
state.categories[key] = { ...existing, pos: updated.pos }; 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) => { .addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null; const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) { if (nextCompanyId != null && nextCompanyId !== state.companyId) {