diff --git a/src/api/index.js b/src/api/index.js index 26bb05e..ad53921 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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' }); + }, }; /** diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx index 20f2002..c12f9a4 100644 --- a/src/components/tiles/CategoryTile.jsx +++ b/src/components/tiles/CategoryTile.jsx @@ -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 ( {isTerminalMode ? ( + ) : isPhotoMode ? ( + ) : ( )} @@ -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 ( - + + + + + {categoryFullHash ? 'Löschen' : 'Hochladen'} + + + + ); + } + return ( - + {nameInput} ); @@ -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 ( + + {thumbHash && !thumbLoaded ? : null} + {fullHash ? ( + + ) : null} + + ); + } +} + /** * 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} @@ -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 ( - {this.renderLabel()} + + + {this.renderLabel()} + ); } @@ -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, }} > - - - + + + + + + + + ); } diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 871b86d..2dd6ea0 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -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={ 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)); diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index 15d9c80..f713ff8 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -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) {