From d5d30fb2861d69769b2fa8a4f78e8e0904336a29 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 20:10:23 +0200 Subject: [PATCH] u --- src/api/index.js | 71 ++++++++++++++++++ src/components/article/ArticlePhoto.jsx | 28 +++++++ src/components/article/ArticlePhotoTile.jsx | 82 +++++++++++++++++++++ src/components/article/ArticleView.jsx | 26 +++---- src/components/panels/EditablePanelTile.jsx | 21 ++---- src/components/panels/PanelTile.jsx | 61 +++++++++++++-- src/components/panels/panelTileLayout.js | 11 +++ src/utils/pasteImage.js | 25 +++++++ src/utils/prepareArticlePhoto.js | 61 +++++++++++++++ 9 files changed, 349 insertions(+), 37 deletions(-) create mode 100644 src/components/article/ArticlePhoto.jsx create mode 100644 src/components/article/ArticlePhotoTile.jsx create mode 100644 src/utils/pasteImage.js create mode 100644 src/utils/prepareArticlePhoto.js diff --git a/src/api/index.js b/src/api/index.js index 1f32b79..0d6b5f1 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -81,6 +81,62 @@ async function http(path, { method = 'GET', body } = {}) { 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 = { search({ target, filter, offset, maxCount } = {}) { const params = new URLSearchParams({ target }); @@ -118,6 +174,21 @@ export const article = { 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 = { diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx new file mode 100644 index 0000000..1b1f861 --- /dev/null +++ b/src/components/article/ArticlePhoto.jsx @@ -0,0 +1,28 @@ +import Box from '@mui/material/Box'; + +export default function ArticlePhoto({ imageUrl }) { + return ( + + {imageUrl ? ( + + ) : null} + + ); +} diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx new file mode 100644 index 0000000..d5f2874 --- /dev/null +++ b/src/components/article/ArticlePhotoTile.jsx @@ -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 ( + <> + + + + + + ); +} diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 8a3830b..6587de8 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; import SingleLineTextEdit from '../SingleLineTextEdit'; +import ArticlePhotoTile from './ArticlePhotoTile'; import { article } from '../../api'; import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels'; import { requireObject } from '../../utils/apiShape'; @@ -32,20 +33,6 @@ function PanelValue({ label, children }) { ); } -function ArticlePhoto() { - return ( - - ); -} - const headerTileSx = { minHeight: 0, }; @@ -155,9 +142,14 @@ export default function ArticleView({ article: articleData, onArticleUpdated }) )} /> - - - + diff --git a/src/components/panels/EditablePanelTile.jsx b/src/components/panels/EditablePanelTile.jsx index 69353b0..0538ba8 100644 --- a/src/components/panels/EditablePanelTile.jsx +++ b/src/components/panels/EditablePanelTile.jsx @@ -11,14 +11,12 @@ import Typography from '@mui/material/Typography'; import { injectMuiTheme } from './injectMuiTheme'; import { enqueueSnackbar } from 'notistack'; import { backendErrorFrom } from '../../utils/backendError'; -import { panelTilePaperSx } from './panelTileLayout'; +import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout'; function valuesEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } -const ACTION_PADDING = 12; - /** * Panel tile with centralized inline-edit chrome. * 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; const { editing, saving } = this.state; - const actionSx = { - position: 'absolute', - top: 4, - color: 'text.secondary', - }; - const iconSx = { fontSize: 28 }; + const iconSx = tileActionIconSx; if (!editing) { return ( @@ -155,7 +148,7 @@ class EditablePanelTileBase extends Component { aria-hidden tabIndex={-1} sx={{ - ...actionSx, + ...tileActionSx, right: 48, visibility: 'hidden', pointerEvents: 'none', @@ -166,7 +159,7 @@ class EditablePanelTileBase extends Component { @@ -181,7 +174,7 @@ class EditablePanelTileBase extends Component { disabled={!this.canSave()} aria-label="Speichern" color={this.canSave() ? 'primary' : 'default'} - sx={{ ...actionSx, right: 48 }} + sx={{ ...tileActionSx, right: 48 }} > {saving ? ( @@ -193,7 +186,7 @@ class EditablePanelTileBase extends Component { onClick={this.handleCancel} disabled={saving} aria-label="Abbrechen" - sx={{ ...actionSx, right: 4 }} + sx={{ ...tileActionSx, right: 4 }} > @@ -207,7 +200,7 @@ class EditablePanelTileBase extends Component { const { editing, draft, error } = this.state; const savedValue = this.getSavedValue(); const pending = this.isDirty(); - const actionPadding = onSave ? ACTION_PADDING : 0; + const actionPadding = onSave ? TILE_ACTION_PADDING : 0; return ( {title} ); } + renderAction() { + const { action } = this.props; + if (!action) return null; + + const { label, icon: Icon, onClick, busy } = action; + + return ( + <> + + + + + {busy ? ( + + ) : ( + + )} + + + ); + } + 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 ( + {this.renderAction()} {this.renderTitle()} - {children} + {children} ); } diff --git a/src/components/panels/panelTileLayout.js b/src/components/panels/panelTileLayout.js index 96c8df2..27ebb95 100644 --- a/src/components/panels/panelTileLayout.js +++ b/src/components/panels/panelTileLayout.js @@ -17,6 +17,17 @@ export function responsiveColumnSpan(wide) { } 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) { if (!tileKey || !theme?.panelTiles) return undefined; diff --git a/src/utils/pasteImage.js b/src/utils/pasteImage.js new file mode 100644 index 0000000..fbe27f1 --- /dev/null +++ b/src/utils/pasteImage.js @@ -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; +} diff --git a/src/utils/prepareArticlePhoto.js b/src/utils/prepareArticlePhoto.js new file mode 100644 index 0000000..288efcb --- /dev/null +++ b/src/utils/prepareArticlePhoto.js @@ -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} + */ +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(); + } +}