This commit is contained in:
seb
2026-07-31 20:10:23 +02:00
parent 7cd053ada6
commit d5d30fb286
9 changed files with 349 additions and 37 deletions

View File

@@ -81,6 +81,62 @@ async function http(path, { method = 'GET', body } = {}) {
return json; return json;
} }
async function httpMultipart(path, { method = 'POST', formData } = {}) {
const url = new URL(path, window.location.origin).href;
logApi('request', {
method,
path,
url,
origin: window.location.origin,
credentials: 'include',
multipart: true,
});
const res = await fetch(path, {
method,
credentials: 'include',
body: formData,
});
const responseHeaders = readableHeaders(res.headers);
logApi('response', {
method,
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
responseHeaders,
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
const json = await res.json();
logApi('ok-body', {
method,
path,
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
hasArticle: Boolean(json?.article),
});
return json;
}
export const article = { export const article = {
search({ target, filter, offset, maxCount } = {}) { search({ target, filter, offset, maxCount } = {}) {
const params = new URLSearchParams({ target }); const params = new URLSearchParams({ target });
@@ -118,6 +174,21 @@ export const article = {
body, body,
}); });
}, },
/**
* POST /api/article/:sku/photos — multipart/form-data
* Body: file (image/avif, max 1000×1000, prepared client-side), optional sort (default 0)
* → 201 { article }
*/
uploadPhoto(sku, file, { sort = 0 } = {}) {
const formData = new FormData();
formData.append('file', file);
formData.append('sort', String(sort));
return httpMultipart(`/api/article/${encodeURIComponent(sku)}/photos`, {
method: 'POST',
formData,
});
},
}; };
export const category = { export const category = {

View File

@@ -0,0 +1,28 @@
import Box from '@mui/material/Box';
export default function ArticlePhoto({ imageUrl }) {
return (
<Box
sx={{
width: 200,
height: 200,
maxWidth: '100%',
borderRadius: 2,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: imageUrl ? 'transparent' : 'action.hover',
}}
>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,82 @@
import { useRef, useState } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import { article } from '../../api';
import { PanelTile } from '../panels';
import { requireObject } from '../../utils/apiShape';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
export default function ArticlePhotoTile({
article: articleData,
onArticleUpdated,
wide = 1,
minHeight = 0,
sx,
tileKey,
}) {
const fileInputRef = useRef(null);
const [busy, setBusy] = useState(false);
const photo = primaryPhoto(articleData);
const imageUrl = photo?.url;
const uploadRawImage = async (rawBlob) => {
setBusy(true);
try {
const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadPhoto(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/photos');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
} finally {
setBusy(false);
}
};
const handlePasteClick = () => {
if (busy) return;
void pasteImageOrFallback(() => {
fileInputRef.current?.click();
}).then((image) => {
if (image) void uploadRawImage(image);
});
};
const handleFileChange = (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void uploadRawImage(file);
};
return (
<>
<PanelTile
wide={wide}
minHeight={minHeight}
sx={sx}
tileKey={tileKey}
action={
imageUrl
? undefined
: {
label: 'Bild einfügen',
icon: ContentPasteIcon,
onClick: handlePasteClick,
busy,
}
}
>
<ArticlePhoto imageUrl={imageUrl} />
</PanelTile>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleFileChange}
/>
</>
);
}

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../SingleLineTextEdit'; import SingleLineTextEdit from '../SingleLineTextEdit';
import ArticlePhotoTile from './ArticlePhotoTile';
import { article } from '../../api'; import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels'; import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
@@ -32,20 +33,6 @@ function PanelValue({ label, children }) {
); );
} }
function ArticlePhoto() {
return (
<Box
sx={{
width: 200,
height: 200,
maxWidth: '100%',
bgcolor: 'action.hover',
borderRadius: 2,
}}
/>
);
}
const headerTileSx = { const headerTileSx = {
minHeight: 0, minHeight: 0,
}; };
@@ -155,9 +142,14 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
)} )}
/> />
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.photo} minHeight={0} sx={headerTileSx}> <ArticlePhotoTile
<ArticlePhoto /> wide={1}
</PanelTile> tileKey={PANEL_TILE_KEYS.photo}
minHeight={0}
sx={headerTileSx}
article={articleData}
onArticleUpdated={onArticleUpdated}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.stock} title="Bestand"> <PanelTile wide={1} tileKey={PANEL_TILE_KEYS.stock} title="Bestand">
<PanelValue> <PanelValue>

View File

@@ -11,14 +11,12 @@ import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme'; import { injectMuiTheme } from './injectMuiTheme';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { panelTilePaperSx } from './panelTileLayout'; import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
function valuesEqual(a, b) { function valuesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
const ACTION_PADDING = 12;
/** /**
* Panel tile with centralized inline-edit chrome. * Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon). * Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
@@ -140,12 +138,7 @@ class EditablePanelTileBase extends Component {
if (!onSave) return null; if (!onSave) return null;
const { editing, saving } = this.state; const { editing, saving } = this.state;
const actionSx = { const iconSx = tileActionIconSx;
position: 'absolute',
top: 4,
color: 'text.secondary',
};
const iconSx = { fontSize: 28 };
if (!editing) { if (!editing) {
return ( return (
@@ -155,7 +148,7 @@ class EditablePanelTileBase extends Component {
aria-hidden aria-hidden
tabIndex={-1} tabIndex={-1}
sx={{ sx={{
...actionSx, ...tileActionSx,
right: 48, right: 48,
visibility: 'hidden', visibility: 'hidden',
pointerEvents: 'none', pointerEvents: 'none',
@@ -166,7 +159,7 @@ class EditablePanelTileBase extends Component {
<IconButton <IconButton
onClick={this.handleStartEdit} onClick={this.handleStartEdit}
aria-label="Bearbeiten" aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }} sx={{ ...tileActionSx, right: 4 }}
> >
<EditOutlinedIcon sx={iconSx} /> <EditOutlinedIcon sx={iconSx} />
</IconButton> </IconButton>
@@ -181,7 +174,7 @@ class EditablePanelTileBase extends Component {
disabled={!this.canSave()} disabled={!this.canSave()}
aria-label="Speichern" aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'} color={this.canSave() ? 'primary' : 'default'}
sx={{ ...actionSx, right: 48 }} sx={{ ...tileActionSx, right: 48 }}
> >
{saving ? ( {saving ? (
<CircularProgress size={22} color="inherit" /> <CircularProgress size={22} color="inherit" />
@@ -193,7 +186,7 @@ class EditablePanelTileBase extends Component {
onClick={this.handleCancel} onClick={this.handleCancel}
disabled={saving} disabled={saving}
aria-label="Abbrechen" aria-label="Abbrechen"
sx={{ ...actionSx, right: 4 }} sx={{ ...tileActionSx, right: 4 }}
> >
<CloseOutlinedIcon sx={iconSx} /> <CloseOutlinedIcon sx={iconSx} />
</IconButton> </IconButton>
@@ -207,7 +200,7 @@ class EditablePanelTileBase extends Component {
const { editing, draft, error } = this.state; const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue(); const savedValue = this.getSavedValue();
const pending = this.isDirty(); const pending = this.isDirty();
const actionPadding = onSave ? ACTION_PADDING : 0; const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
return ( return (
<Paper <Paper

View File

@@ -1,27 +1,75 @@
import { Component } from 'react'; import { Component, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { injectMuiTheme } from './injectMuiTheme'; import { injectMuiTheme } from './injectMuiTheme';
import { GRID_COLUMNS, panelTilePaperSx } from './panelTileLayout'; import {
GRID_COLUMNS,
TILE_ACTION_PADDING,
panelTilePaperSx,
tileActionIconSx,
tileActionSx,
} from './panelTileLayout';
class PanelTileBase extends Component { class PanelTileBase extends Component {
renderTitle() { renderTitle() {
const { title } = this.props; const { title, action } = this.props;
if (!title) return null; if (!title) return null;
return ( return (
<Typography <Typography
variant="overline" variant="overline"
color="text.secondary" color="text.secondary"
sx={{ display: 'block', mb: 1 }} sx={{ display: 'block', mb: 1, pr: action ? 9 : 0 }}
> >
{title} {title}
</Typography> </Typography>
); );
} }
renderAction() {
const { action } = this.props;
if (!action) return null;
const { label, icon: Icon, onClick, busy } = action;
return (
<>
<IconButton
disabled
aria-hidden
tabIndex={-1}
sx={{
...tileActionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<SaveOutlinedIcon sx={tileActionIconSx} />
</IconButton>
<IconButton
type="button"
onClick={onClick}
aria-label={label}
disabled={busy}
sx={{ ...tileActionSx, right: 4 }}
>
{busy ? (
<CircularProgress size={22} color="inherit" />
) : (
<Icon sx={tileActionIconSx} />
)}
</IconButton>
</>
);
}
render() { render() {
const { theme, wide = 1, children, sx, minHeight = 120, tileKey } = this.props; const { theme, wide = 1, children, sx, minHeight = 120, tileKey, action } = this.props;
const actionPadding = action ? TILE_ACTION_PADDING : 0;
return ( return (
<Paper <Paper
@@ -34,8 +82,9 @@ class PanelTileBase extends Component {
theme, theme,
})} })}
> >
{this.renderAction()}
{this.renderTitle()} {this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box> <Box sx={{ flex: 1, minWidth: 0, pr: actionPadding }}>{children}</Box>
</Paper> </Paper>
); );
} }

View File

@@ -17,6 +17,17 @@ export function responsiveColumnSpan(wide) {
} }
export const PENDING_EDIT_RING_PX = 4; export const PENDING_EDIT_RING_PX = 4;
export const TILE_ACTION_PADDING = 12;
export const tileActionSx = {
position: 'absolute',
top: 4,
color: 'text.secondary',
};
export const tileActionIconSx = {
fontSize: 28,
};
export function panelTileBackground(theme, tileKey) { export function panelTileBackground(theme, tileKey) {
if (!tileKey || !theme?.panelTiles) return undefined; if (!tileKey || !theme?.panelTiles) return undefined;

25
src/utils/pasteImage.js Normal file
View File

@@ -0,0 +1,25 @@
export async function readClipboardImage() {
const items = await navigator.clipboard.read();
for (const item of items) {
for (const type of item.types) {
if (type.startsWith('image/')) {
return item.getType(type);
}
}
}
return null;
}
/**
* Try clipboard image first; run onMissing when none found or clipboard unavailable.
*/
export async function pasteImageOrFallback(onMissing) {
try {
const image = await readClipboardImage();
if (image) return image;
} catch {
// Clipboard API unavailable or denied.
}
onMissing();
return null;
}

View File

@@ -0,0 +1,61 @@
const MAX_EDGE = 1000;
const AVIF_MIME = 'image/avif';
const AVIF_QUALITY = 0.8;
function scaledDimensions(width, height) {
const scale = Math.min(1, MAX_EDGE / width, MAX_EDGE / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
};
}
/**
* Resize to fit within 1000×1000 (aspect ratio kept, no upscale) and encode as AVIF.
* @param {Blob|File} input
* @returns {Promise<File>}
*/
export async function prepareArticlePhoto(input) {
if (!(input instanceof Blob)) {
throw new Error('Kein Bild.');
}
if (!input.type.startsWith('image/')) {
throw new Error('Kein Bild.');
}
const bitmap = await createImageBitmap(input);
try {
const { width, height } = scaledDimensions(bitmap.width, bitmap.height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas nicht verfügbar.');
}
ctx.drawImage(bitmap, 0, 0, width, height);
const avifBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob || blob.type !== AVIF_MIME) {
reject(new Error('AVIF wird von diesem Browser nicht unterstützt.'));
return;
}
resolve(blob);
},
AVIF_MIME,
AVIF_QUALITY,
);
});
const baseName = input instanceof File && input.name
? input.name.replace(/\.[^.]+$/, '')
: 'photo';
return new File([avifBlob], `${baseName}.avif`, { type: AVIF_MIME });
} finally {
bitmap.close();
}
}