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();
+ }
+}