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:
@@ -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' });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user