diff --git a/.cursor/rules/prefer-class-components.mdc b/.cursor/rules/prefer-class-components.mdc new file mode 100644 index 0000000..811a245 --- /dev/null +++ b/.cursor/rules/prefer-class-components.mdc @@ -0,0 +1,33 @@ +--- +description: React-Komponenten bevorzugt als Class Components schreiben +globs: src/**/*.{jsx,js,tsx,ts} +alwaysApply: false +--- + +# Class Components bevorzugen + +Neue und geänderte React-Komponenten als **Class Components** (`extends Component`), nicht als Function Components. + +```js +// ❌ +export default function ArticlePhoto({ photo }) { + return {photo?.thumbHash}; +} + +// ✅ +class ArticlePhoto extends Component { + render() { + const { photo } = this.props; + return {photo?.thumbHash}; + } +} +export default ArticlePhoto; +``` + +## Konventionen + +- `import { Component } from 'react'` +- Event-Handler im Constructor binden (`this.handleX = this.handleX.bind(this)`) +- State über `this.state` / `this.setState` +- Lifecycle: `componentDidMount`, `componentDidUpdate`, `componentWillUnmount` statt Hooks +- Keine neuen Function Components und keine neuen Hooks (`useState`, `useEffect`, …), außer der Bestandscode ist bereits so und bleibt unangetastet diff --git a/src/api/index.js b/src/api/index.js index 095b751..17abed9 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -180,7 +180,7 @@ export const article = { /** * POST /api/article/:sku/image * Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM) - * → 201 { image: { picId, sort, full, thumb, hash, thumbHash } } + * → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } } */ uploadImage(sku, file) { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { @@ -191,6 +191,20 @@ export const article = { }, }; +/** + * Content-addressed thumb AVIF by fullHash (SHA-256 hex of full image bytes). + * Auth: session cookie. Browser-cacheable (private, immutable, ETag). + */ +export const thumb = { + /** + * GET /api/thumb/:fullHash → image/avif (thumb bytes) + * Use as (same-origin; cookies sent). + */ + url(fullHash) { + return `/api/thumb/${encodeURIComponent(fullHash)}`; + }, +}; + export const category = { /** GET /api/category/tree → { languageId, tree, articles } (company from session) */ getTree() { @@ -310,4 +324,4 @@ export const auth = { }, }; -export default { auth, article, category, company }; +export default { auth, article, category, company, thumb }; diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index 1b1f861..764f4ab 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -1,28 +1,90 @@ +import { Component } from 'react'; import Box from '@mui/material/Box'; +import { thumb } from '../../api'; +import ThumbHashImage from './ThumbHashImage'; -export default function ArticlePhoto({ imageUrl }) { - return ( - - {imageUrl ? ( - - ) : null} - - ); +class ArticlePhoto extends Component { + constructor(props) { + super(props); + this.state = { thumbLoaded: false }; + this.handleThumbLoad = this.handleThumbLoad.bind(this); + this.handleThumbError = this.handleThumbError.bind(this); + } + + componentDidUpdate(prevProps) { + if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) { + this.setState({ thumbLoaded: false }); + } + } + + handleThumbLoad() { + this.setState({ thumbLoaded: true }); + } + + handleThumbError() { + this.setState({ thumbLoaded: false }); + } + + render() { + const { photo } = this.props; + const { thumbLoaded } = this.state; + const fullHash = photo?.fullHash; + const hasPreview = photo?.thumbHash != null; + const aspectRatio = + photo?.thumbW != null && photo?.thumbH != null + ? `${photo.thumbW} / ${photo.thumbH}` + : '1 / 1'; + + return ( + + {hasPreview && !thumbLoaded ? ( + = photo.thumbH ? '100%' : 'auto', + height: photo.thumbH > photo.thumbW ? '100%' : 'auto', + }} + > + + + ) : null} + {fullHash ? ( + + ) : null} + + ); + } } + +export default ArticlePhoto; diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index 9b0055a..176add8 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -13,14 +13,14 @@ export default function ArticlePhotoTile({ article: articleData, onArticleUpdated, wide = 1, - minHeight = 0, + minHeight = 200, sx, tileKey, }) { const fileInputRef = useRef(null); const [busy, setBusy] = useState(false); const photo = primaryPhoto(articleData); - const imageUrl = photo?.url; + const hasPhoto = photo?.thumbHash != null; const uploadRawImage = async (rawBlob) => { setBusy(true); @@ -61,7 +61,7 @@ export default function ArticlePhotoTile({ sx={sx} tileKey={tileKey} action={ - imageUrl + hasPhoto ? undefined : { label: 'Bild einfügen', @@ -71,7 +71,7 @@ export default function ArticlePhotoTile({ } } > - + c.charCodeAt(0)); + return thumbHashToRGBA(bytes); +} + +function paintThumbHash(canvas, thumbHash) { + const parent = canvas.parentElement; + if (!parent) return; + + const { width, height } = parent.getBoundingClientRect(); + if (width === 0 || height === 0) return; + + const { w, h, rgba } = decodeThumbHash(thumbHash); + const src = document.createElement('canvas'); + src.width = w; + src.height = h; + src.getContext('2d').putImageData( + new ImageData(new Uint8ClampedArray(rgba), w, h), + 0, + 0, + ); + + canvas.width = Math.round(width); + canvas.height = Math.round(height); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(src, 0, 0, canvas.width, canvas.height); +} + +export default function ThumbHashImage({ thumbHash, sx }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !thumbHash) return; + + paintThumbHash(canvas, thumbHash); + + const parent = canvas.parentElement; + if (!parent || typeof ResizeObserver === 'undefined') return undefined; + + const observer = new ResizeObserver(() => { + paintThumbHash(canvas, thumbHash); + }); + observer.observe(parent); + return () => observer.disconnect(); + }, [thumbHash]); + + return ( + + ); +} diff --git a/src/utils/articleDetail.js b/src/utils/articleDetail.js index 4578138..fc9c121 100644 --- a/src/utils/articleDetail.js +++ b/src/utils/articleDetail.js @@ -10,10 +10,11 @@ export function primaryPhoto(article) { return photos[0] ?? null; } -/** Map POST /api/article/:sku/image → photos[] entry shape. */ +/** Map POST /api/article/:sku/image → photos[] entry shape (GET article). */ export function photoFromUploadedImage(image) { return { sort: image.sort, + fullHash: image.fullHash, thumbW: image.thumb.w, thumbH: image.thumb.h, thumbHash: image.thumbHash,