From 9582370b0b035e8b1770b6abcf5151d1b3fa87fe Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 20:37:43 +0200 Subject: [PATCH] u --- package-lock.json | 32 ++++++- package.json | 5 +- public/fixtures/from-payload.png | Bin 0 -> 281 bytes public/fixtures/sample-article.png | Bin 0 -> 10540 bytes public/fixtures/thumbhash-decoded-200.png | Bin 0 -> 3450 bytes .../thumbhash-internal-resolution.png | Bin 0 -> 1725 bytes src/api/index.js | 24 ++--- src/components/AuthBootstrap.jsx | 4 +- src/components/LoginPage.jsx | 3 +- src/components/article/ArticlePhotoTile.jsx | 7 +- src/components/panels/EditablePanelTile.jsx | 5 +- src/main.jsx | 2 +- src/utils/notify.js | 20 ++++ src/utils/prepareArticlePhoto.js | 86 +++++++++++------- vite.config.js | 56 +++++++++--- 15 files changed, 174 insertions(+), 70 deletions(-) create mode 100644 public/fixtures/from-payload.png create mode 100644 public/fixtures/sample-article.png create mode 100644 public/fixtures/thumbhash-decoded-200.png create mode 100644 public/fixtures/thumbhash-internal-resolution.png create mode 100644 src/utils/notify.js diff --git a/package-lock.json b/package-lock.json index 98587de..2b37e1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@jsquash/avif": "^2.1.1", + "@jsquash/resize": "^2.1.1", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", "@reduxjs/toolkit": "^2.12.0", @@ -17,7 +19,8 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-redux": "^9.3.0", - "react-router-dom": "^7.18.2" + "react-router-dom": "^7.18.2", + "thumbhash": "^0.1.1" }, "devDependencies": { "@vitejs/plugin-react": "6.0.2", @@ -1828,6 +1831,21 @@ "@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": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", @@ -6712,6 +6730,12 @@ "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": { "version": "0.2.17", "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 2701444..3f18bad 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@jsquash/avif": "^2.1.1", + "@jsquash/resize": "^2.1.1", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", "@reduxjs/toolkit": "^2.12.0", @@ -23,7 +25,8 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-redux": "^9.3.0", - "react-router-dom": "^7.18.2" + "react-router-dom": "^7.18.2", + "thumbhash": "^0.1.1" }, "devDependencies": { "@vitejs/plugin-react": "6.0.2", diff --git a/public/fixtures/from-payload.png b/public/fixtures/from-payload.png new file mode 100644 index 0000000000000000000000000000000000000000..54e2aaeba4f831b5f3fdcac998e999e5ef1e649c GIT binary patch literal 281 zcmeAS@N?(olHy`uVBq!ia0y~yV3Yu|H8_}nmdKI;Vst04WPe{r~^~ literal 0 HcmV?d00001 diff --git a/public/fixtures/sample-article.png b/public/fixtures/sample-article.png new file mode 100644 index 0000000000000000000000000000000000000000..958ad4d3b06e051bbf9edf3fd08d17717b435ef7 GIT binary patch literal 10540 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 Tue(OT00000NkvXXu0mjfz;`ds literal 0 HcmV?d00001 diff --git a/src/api/index.js b/src/api/index.js index 0d6b5f1..0cde0d8 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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, }); }, }; diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index ad4834a..e67985c 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -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); }); } diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 53027bd..d804153 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -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, diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index d5f2874..aaf6dbb 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -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); } diff --git a/src/components/panels/EditablePanelTile.jsx b/src/components/panels/EditablePanelTile.jsx index 0538ba8..6135551 100644 --- a/src/components/panels/EditablePanelTile.jsx +++ b/src/components/panels/EditablePanelTile.jsx @@ -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' }); } }; diff --git a/src/main.jsx b/src/main.jsx index f32854d..d1a2f83 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -30,7 +30,7 @@ createRoot(document.getElementById('root')).render( diff --git a/src/utils/notify.js b/src/utils/notify.js new file mode 100644 index 0000000..4882f35 --- /dev/null +++ b/src/utils/notify.js @@ -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' }); +} diff --git a/src/utils/prepareArticlePhoto.js b/src/utils/prepareArticlePhoto.js index 288efcb..8d24190 100644 --- a/src/utils/prepareArticlePhoto.js +++ b/src/utils/prepareArticlePhoto.js @@ -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} */ @@ -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 }); } diff --git a/vite.config.js b/vite.config.js index 4e24a70..59d2b24 100644 --- a/vite.config.js +++ b/vite.config.js @@ -31,10 +31,31 @@ function truncate(text, max = 500) { 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 === 204 || body === '') return '(empty)'; - return truncate(body); + if (statusCode === 204 || !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 hasRequestBody(method) { @@ -57,9 +78,11 @@ const sharedServer = { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { - req._proxyRequestBody = chunks.length - ? Buffer.concat(chunks).toString('utf8') - : ''; + const buffer = chunks.length ? Buffer.concat(chunks) : null; + req._proxyRequestBody = formatRequestBody( + req.headers['content-type'] ?? '', + buffer, + ); }); }); @@ -75,9 +98,9 @@ const sharedServer = { const responseChunks = []; proxyRes.on('data', (chunk) => responseChunks.push(chunk)); proxyRes.on('end', () => { - const responseBody = responseChunks.length - ? Buffer.concat(responseChunks).toString('utf8') - : ''; + const responseBuffer = responseChunks.length + ? Buffer.concat(responseChunks) + : null; const parts = [ '[vite-proxy]', req.method, @@ -85,14 +108,16 @@ const sharedServer = { String(proxyRes.statusCode), ]; if (hasRequestBody(req.method)) { - parts.push( - `body=${req._proxyRequestBody ? truncate(req._proxyRequestBody) : '(empty)'}`, - ); + parts.push(`body=${req._proxyRequestBody ?? '(empty)'}`); } if (cookies?.length) { 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(' ')); }); }); @@ -115,6 +140,10 @@ export default defineConfig(({ command }) => ({ name: 'mui-vendor', test: /node_modules\/(@mui|@emotion)\//, }, + { + name: 'jsquash-image', + test: /node_modules\/@jsquash\/(avif|resize)\//, + }, ], }, }, @@ -157,6 +186,7 @@ export default defineConfig(({ command }) => ({ }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], + globIgnores: ['**/*.wasm'], navigateFallback: 'index.html', navigateFallbackDenylist: [/^\/api/], },