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 0000000..54e2aae Binary files /dev/null and b/public/fixtures/from-payload.png differ diff --git a/public/fixtures/sample-article.png b/public/fixtures/sample-article.png new file mode 100644 index 0000000..958ad4d Binary files /dev/null and b/public/fixtures/sample-article.png differ diff --git a/public/fixtures/thumbhash-decoded-200.png b/public/fixtures/thumbhash-decoded-200.png new file mode 100644 index 0000000..78df6fd Binary files /dev/null and b/public/fixtures/thumbhash-decoded-200.png differ diff --git a/public/fixtures/thumbhash-internal-resolution.png b/public/fixtures/thumbhash-internal-resolution.png new file mode 100644 index 0000000..172a120 Binary files /dev/null and b/public/fixtures/thumbhash-internal-resolution.png differ 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/], },