u
This commit is contained in:
@@ -8,7 +8,7 @@ function logApi(tag, details) {
|
||||
} catch {
|
||||
text = String(details);
|
||||
}
|
||||
console.log(`[api] ${tag} ${text}`);
|
||||
console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`);
|
||||
}
|
||||
|
||||
function readableHeaders(headers) {
|
||||
@@ -81,7 +81,7 @@ async function http(path, { method = 'GET', body } = {}) {
|
||||
return json;
|
||||
}
|
||||
|
||||
async function httpMultipart(path, { method = 'POST', formData } = {}) {
|
||||
async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
|
||||
const url = new URL(path, window.location.origin).href;
|
||||
|
||||
logApi('request', {
|
||||
@@ -90,13 +90,15 @@ async function httpMultipart(path, { method = 'POST', formData } = {}) {
|
||||
url,
|
||||
origin: window.location.origin,
|
||||
credentials: 'include',
|
||||
multipart: true,
|
||||
contentType,
|
||||
bodyBytes: body?.byteLength ?? body?.size ?? null,
|
||||
});
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': contentType },
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
body,
|
||||
});
|
||||
|
||||
const responseHeaders = readableHeaders(res.headers);
|
||||
@@ -176,17 +178,15 @@ export const article = {
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/photos — multipart/form-data
|
||||
* Body: file (image/avif, max 1000×1000, prepared client-side), optional sort (default 0)
|
||||
* POST /api/article/:sku/image
|
||||
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
|
||||
* → 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`, {
|
||||
uploadImage(sku, file) {
|
||||
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
|
||||
method: 'POST',
|
||||
formData,
|
||||
contentType: 'image/avif',
|
||||
body: file,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
let sessionBootstrapped = false;
|
||||
|
||||
@@ -19,7 +19,7 @@ class AuthBootstrap extends Component {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
enqueueSnackbar(`Session check failed: ${err.message}`, { variant: 'error' });
|
||||
notifyError(err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { connect } from 'react-redux';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
|
||||
import { notifyErrorMessage } from '../utils/notify';
|
||||
import {
|
||||
abortGoogleSignIn,
|
||||
FEDCM_BLOCKED_MESSAGE,
|
||||
|
||||
@@ -3,6 +3,7 @@ import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import { article } from '../../api';
|
||||
import { PanelTile } from '../panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||
import { primaryPhoto } from '../../utils/articleDetail';
|
||||
@@ -25,10 +26,12 @@ export default function ArticlePhotoTile({
|
||||
setBusy(true);
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const res = await article.uploadPhoto(articleData.sku, file);
|
||||
requireObject(res, 'POST /api/article/:sku/photos');
|
||||
const res = await article.uploadImage(articleData.sku, file);
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { injectMuiTheme } from './injectMuiTheme';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
|
||||
|
||||
function valuesEqual(a, b) {
|
||||
@@ -113,9 +113,8 @@ class EditablePanelTileBase extends Component {
|
||||
this.setState({ editing: false, draft: null, saving: false, error: null });
|
||||
enqueueSnackbar('Gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
const normalized = backendErrorFrom(err);
|
||||
const normalized = notifyError(err);
|
||||
this.setState({ saving: false, error: normalized.message });
|
||||
enqueueSnackbar(normalized.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
autoHideDuration={2000}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AuthBootstrap>
|
||||
<App />
|
||||
|
||||
20
src/utils/notify.js
Normal file
20
src/utils/notify.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { backendErrorFrom } from './backendError';
|
||||
|
||||
/** Show error toast and log to console for debugging. */
|
||||
export function notifyError(err) {
|
||||
const normalized = backendErrorFrom(err);
|
||||
console.error('[error]', normalized.message, {
|
||||
status: normalized.status,
|
||||
path: normalized.path,
|
||||
method: normalized.method,
|
||||
err,
|
||||
});
|
||||
enqueueSnackbar(normalized.message, { variant: 'error' });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function notifyErrorMessage(message) {
|
||||
console.error('[error]', message);
|
||||
enqueueSnackbar(message, { variant: 'error' });
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
const MAX_EDGE = 1000;
|
||||
const AVIF_MIME = 'image/avif';
|
||||
const AVIF_QUALITY = 0.8;
|
||||
const AVIF_QUALITY = 80;
|
||||
|
||||
let encodeAvif;
|
||||
let resizeImage;
|
||||
|
||||
function scaledDimensions(width, height) {
|
||||
const scale = Math.min(1, MAX_EDGE / width, MAX_EDGE / height);
|
||||
@@ -10,8 +13,43 @@ function scaledDimensions(width, height) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvifEncoder() {
|
||||
if (!encodeAvif) {
|
||||
const { default: encode } = await import('@jsquash/avif/encode');
|
||||
encodeAvif = encode;
|
||||
}
|
||||
return encodeAvif;
|
||||
}
|
||||
|
||||
async function getResizer() {
|
||||
if (!resizeImage) {
|
||||
const { default: resize } = await import('@jsquash/resize');
|
||||
resizeImage = resize;
|
||||
}
|
||||
return resizeImage;
|
||||
}
|
||||
|
||||
async function decodeToImageData(input) {
|
||||
const bitmap = await createImageBitmap(input);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas nicht verfügbar.');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize to fit within 1000×1000 (aspect ratio kept, no upscale) and encode as AVIF.
|
||||
* Resize to fit within 1000×1000 (aspect ratio kept, no upscale).
|
||||
* Encodes as AVIF via @jsquash/avif (WASM).
|
||||
* @param {Blob|File} input
|
||||
* @returns {Promise<File>}
|
||||
*/
|
||||
@@ -23,39 +61,21 @@ export async function prepareArticlePhoto(input) {
|
||||
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 originalImageData = await decodeToImageData(input);
|
||||
const { width, height } = scaledDimensions(
|
||||
originalImageData.width,
|
||||
originalImageData.height,
|
||||
);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas nicht verfügbar.');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
const resize = await getResizer();
|
||||
const imageData = await resize(originalImageData, { 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 encode = await getAvifEncoder();
|
||||
const avifBuffer = await encode(imageData, { quality: AVIF_QUALITY });
|
||||
|
||||
const baseName = input instanceof File && input.name
|
||||
? input.name.replace(/\.[^.]+$/, '')
|
||||
: 'photo';
|
||||
const baseName = input instanceof File && input.name
|
||||
? input.name.replace(/\.[^.]+$/, '')
|
||||
: 'photo';
|
||||
|
||||
return new File([avifBlob], `${baseName}.avif`, { type: AVIF_MIME });
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
return new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user