This commit is contained in:
seb
2026-07-31 20:37:43 +02:00
parent d5d30fb286
commit 9582370b0b
15 changed files with 174 additions and 70 deletions

32
package-lock.json generated
View File

@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@jsquash/avif": "^2.1.1",
"@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.2.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0", "@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
@@ -17,7 +19,8 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router-dom": "^7.18.2" "react-router-dom": "^7.18.2",
"thumbhash": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
@@ -1828,6 +1831,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsquash/avif": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@jsquash/avif/-/avif-2.1.1.tgz",
"integrity": "sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA==",
"license": "Apache-2.0",
"dependencies": {
"wasm-feature-detect": "^1.2.11"
}
},
"node_modules/@jsquash/resize": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@jsquash/resize/-/resize-2.1.1.tgz",
"integrity": "sha512-0R5UL1ZLHUT+carjVikcE1QfA+kfNQ2YamYyGVRmhfh4zttU5EY3bQBGxPIPtY2xIAw1P4Kgyxm2xrceRw1r2w==",
"license": "Apache-2.0"
},
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz",
@@ -6712,6 +6730,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -7084,6 +7108,12 @@
} }
} }
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View File

@@ -16,6 +16,8 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@jsquash/avif": "^2.1.1",
"@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.2.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0", "@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
@@ -23,7 +25,8 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router-dom": "^7.18.2" "react-router-dom": "^7.18.2",
"thumbhash": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -8,7 +8,7 @@ function logApi(tag, details) {
} catch { } catch {
text = String(details); text = String(details);
} }
console.log(`[api] ${tag} ${text}`); console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`);
} }
function readableHeaders(headers) { function readableHeaders(headers) {
@@ -81,7 +81,7 @@ async function http(path, { method = 'GET', body } = {}) {
return json; 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; const url = new URL(path, window.location.origin).href;
logApi('request', { logApi('request', {
@@ -90,13 +90,15 @@ async function httpMultipart(path, { method = 'POST', formData } = {}) {
url, url,
origin: window.location.origin, origin: window.location.origin,
credentials: 'include', credentials: 'include',
multipart: true, contentType,
bodyBytes: body?.byteLength ?? body?.size ?? null,
}); });
const res = await fetch(path, { const res = await fetch(path, {
method, method,
headers: { 'Content-Type': contentType },
credentials: 'include', credentials: 'include',
body: formData, body,
}); });
const responseHeaders = readableHeaders(res.headers); const responseHeaders = readableHeaders(res.headers);
@@ -176,17 +178,15 @@ export const article = {
}, },
/** /**
* POST /api/article/:sku/photos — multipart/form-data * POST /api/article/:sku/image
* Body: file (image/avif, max 1000×1000, prepared client-side), optional sort (default 0) * Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
* → 201 { article } * → 201 { article }
*/ */
uploadPhoto(sku, file, { sort = 0 } = {}) { uploadImage(sku, file) {
const formData = new FormData(); return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
formData.append('file', file);
formData.append('sort', String(sort));
return httpMultipart(`/api/article/${encodeURIComponent(sku)}/photos`, {
method: 'POST', method: 'POST',
formData, contentType: 'image/avif',
body: file,
}); });
}, },
}; };

View File

@@ -1,7 +1,7 @@
import { Component } from 'react'; import { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice'; import { notifyError } from '../utils/notify';
let sessionBootstrapped = false; let sessionBootstrapped = false;
@@ -19,7 +19,7 @@ class AuthBootstrap extends Component {
} }
}) })
.catch((err) => { .catch((err) => {
enqueueSnackbar(`Session check failed: ${err.message}`, { variant: 'error' }); notifyError(err);
}); });
} }

View File

@@ -3,8 +3,7 @@ import { connect } from 'react-redux';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google'; import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack'; import { notifyErrorMessage } from '../utils/notify';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
import { import {
abortGoogleSignIn, abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE, FEDCM_BLOCKED_MESSAGE,

View File

@@ -3,6 +3,7 @@ import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import { article } from '../../api'; import { article } from '../../api';
import { PanelTile } from '../panels'; import { PanelTile } from '../panels';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto'; import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage'; import { pasteImageOrFallback } from '../../utils/pasteImage';
import { primaryPhoto } from '../../utils/articleDetail'; import { primaryPhoto } from '../../utils/articleDetail';
@@ -25,10 +26,12 @@ export default function ArticlePhotoTile({
setBusy(true); setBusy(true);
try { try {
const file = await prepareArticlePhoto(rawBlob); const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadPhoto(articleData.sku, file); const res = await article.uploadImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/photos'); requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
onArticleUpdated?.(res.article); onArticleUpdated?.(res.article);
} catch (err) {
notifyError(err);
} finally { } finally {
setBusy(false); setBusy(false);
} }

View File

@@ -10,7 +10,7 @@ import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; 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 { notifyError } from '../../utils/notify';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout'; import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
function valuesEqual(a, b) { function valuesEqual(a, b) {
@@ -113,9 +113,8 @@ class EditablePanelTileBase extends Component {
this.setState({ editing: false, draft: null, saving: false, error: null }); this.setState({ editing: false, draft: null, saving: false, error: null });
enqueueSnackbar('Gespeichert.', { variant: 'success' }); enqueueSnackbar('Gespeichert.', { variant: 'success' });
} catch (err) { } catch (err) {
const normalized = backendErrorFrom(err); const normalized = notifyError(err);
this.setState({ saving: false, error: normalized.message }); this.setState({ saving: false, error: normalized.message });
enqueueSnackbar(normalized.message, { variant: 'error' });
} }
}; };

View File

@@ -30,7 +30,7 @@ createRoot(document.getElementById('root')).render(
<SnackbarProvider <SnackbarProvider
maxSnack={3} maxSnack={3}
autoHideDuration={2000} autoHideDuration={2000}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
> >
<AuthBootstrap> <AuthBootstrap>
<App /> <App />

20
src/utils/notify.js Normal file
View 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' });
}

View File

@@ -1,6 +1,9 @@
const MAX_EDGE = 1000; const MAX_EDGE = 1000;
const AVIF_MIME = 'image/avif'; const AVIF_MIME = 'image/avif';
const AVIF_QUALITY = 0.8; const AVIF_QUALITY = 80;
let encodeAvif;
let resizeImage;
function scaledDimensions(width, height) { function scaledDimensions(width, height) {
const scale = Math.min(1, MAX_EDGE / width, MAX_EDGE / 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 * @param {Blob|File} input
* @returns {Promise<File>} * @returns {Promise<File>}
*/ */
@@ -23,39 +61,21 @@ export async function prepareArticlePhoto(input) {
throw new Error('Kein Bild.'); throw new Error('Kein Bild.');
} }
const bitmap = await createImageBitmap(input); const originalImageData = await decodeToImageData(input);
try { const { width, height } = scaledDimensions(
const { width, height } = scaledDimensions(bitmap.width, bitmap.height); originalImageData.width,
const canvas = document.createElement('canvas'); originalImageData.height,
canvas.width = width; );
canvas.height = height;
const ctx = canvas.getContext('2d'); const resize = await getResizer();
if (!ctx) { const imageData = await resize(originalImageData, { width, height });
throw new Error('Canvas nicht verfügbar.');
}
ctx.drawImage(bitmap, 0, 0, width, height);
const avifBlob = await new Promise((resolve, reject) => { const encode = await getAvifEncoder();
canvas.toBlob( const avifBuffer = await encode(imageData, { quality: AVIF_QUALITY });
(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 const baseName = input instanceof File && input.name
? input.name.replace(/\.[^.]+$/, '') ? input.name.replace(/\.[^.]+$/, '')
: 'photo'; : 'photo';
return new File([avifBlob], `${baseName}.avif`, { type: AVIF_MIME }); return new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
} finally {
bitmap.close();
}
} }

View File

@@ -31,10 +31,31 @@ function truncate(text, max = 500) {
return `${s.slice(0, max)}… (${s.length} chars)`; return `${s.slice(0, max)}… (${s.length} chars)`;
} }
function formatLoggedBody(statusCode, body) { function isBinaryContentType(contentType = '') {
const type = contentType.split(';')[0].trim().toLowerCase();
if (!type) return false;
if (type.startsWith('image/')) return true;
if (type === 'application/octet-stream') return true;
return false;
}
function formatRequestBody(contentType, buffer) {
if (!buffer || buffer.length === 0) return '(empty)';
if (isBinaryContentType(contentType)) {
const type = contentType.split(';')[0].trim();
return `(binary ${type}, ${buffer.length} bytes)`;
}
return truncate(buffer.toString('utf8'));
}
function formatResponseBody(statusCode, contentType, buffer) {
if (statusCode === 304) return '(304 no body)'; if (statusCode === 304) return '(304 no body)';
if (statusCode === 204 || body === '') return '(empty)'; if (statusCode === 204 || !buffer || buffer.length === 0) return '(empty)';
return truncate(body); if (isBinaryContentType(contentType)) {
const type = contentType.split(';')[0].trim();
return `(binary ${type}, ${buffer.length} bytes)`;
}
return truncate(buffer.toString('utf8'));
} }
function hasRequestBody(method) { function hasRequestBody(method) {
@@ -57,9 +78,11 @@ const sharedServer = {
const chunks = []; const chunks = [];
req.on('data', (chunk) => chunks.push(chunk)); req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => { req.on('end', () => {
req._proxyRequestBody = chunks.length const buffer = chunks.length ? Buffer.concat(chunks) : null;
? Buffer.concat(chunks).toString('utf8') req._proxyRequestBody = formatRequestBody(
: ''; req.headers['content-type'] ?? '',
buffer,
);
}); });
}); });
@@ -75,9 +98,9 @@ const sharedServer = {
const responseChunks = []; const responseChunks = [];
proxyRes.on('data', (chunk) => responseChunks.push(chunk)); proxyRes.on('data', (chunk) => responseChunks.push(chunk));
proxyRes.on('end', () => { proxyRes.on('end', () => {
const responseBody = responseChunks.length const responseBuffer = responseChunks.length
? Buffer.concat(responseChunks).toString('utf8') ? Buffer.concat(responseChunks)
: ''; : null;
const parts = [ const parts = [
'[vite-proxy]', '[vite-proxy]',
req.method, req.method,
@@ -85,14 +108,16 @@ const sharedServer = {
String(proxyRes.statusCode), String(proxyRes.statusCode),
]; ];
if (hasRequestBody(req.method)) { if (hasRequestBody(req.method)) {
parts.push( parts.push(`body=${req._proxyRequestBody ?? '(empty)'}`);
`body=${req._proxyRequestBody ? truncate(req._proxyRequestBody) : '(empty)'}`,
);
} }
if (cookies?.length) { if (cookies?.length) {
parts.push(`set-cookie=${cookies.map((c) => c.split(';')[0]).join(',')}`); parts.push(`set-cookie=${cookies.map((c) => c.split(';')[0]).join(',')}`);
} }
parts.push(formatLoggedBody(proxyRes.statusCode, responseBody)); parts.push(formatResponseBody(
proxyRes.statusCode,
proxyRes.headers['content-type'] ?? '',
responseBuffer,
));
console.log(parts.join(' ')); console.log(parts.join(' '));
}); });
}); });
@@ -115,6 +140,10 @@ export default defineConfig(({ command }) => ({
name: 'mui-vendor', name: 'mui-vendor',
test: /node_modules\/(@mui|@emotion)\//, test: /node_modules\/(@mui|@emotion)\//,
}, },
{
name: 'jsquash-image',
test: /node_modules\/@jsquash\/(avif|resize)\//,
},
], ],
}, },
}, },
@@ -157,6 +186,7 @@ export default defineConfig(({ command }) => ({
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
globIgnores: ['**/*.wasm'],
navigateFallback: 'index.html', navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/], navigateFallbackDenylist: [/^\/api/],
}, },