feat(image): enhance image handling with focal points and responsive support
- Added support for extracting focal points and alt text from CMS image fields. - Introduced responsive image handling in TopBanner component, allowing for AVIF/WebP sources and LQIP placeholders. - Updated image transformation functions to accommodate focal point parameters for better cropping. - Refactored image URL resolution logic across various components and routes to utilize new image field structure.
This commit is contained in:
+81
-22
@@ -3,30 +3,60 @@
|
||||
* Der Endpoint holt bei Bedarf vom CMS /api/transform und legt Bytes unter IMAGE_CACHE_DIR ab.
|
||||
*/
|
||||
|
||||
export interface CmsFocal {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CmsImageField {
|
||||
url: string;
|
||||
focal?: CmsFocal;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL.
|
||||
* Für Browser-seitigen Code (kein Node.js/fs nötig).
|
||||
* Extrahiert URL + Focal + Alt aus einem CMS-Image-Field (string oder {src, alt?, focal?}).
|
||||
* Normalisiert Protokoll-lose URLs zu https:.
|
||||
*/
|
||||
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
|
||||
export function extractCmsImageField(field: unknown): CmsImageField | null {
|
||||
if (typeof field === 'string') {
|
||||
const s = field.trim();
|
||||
if (!s) return null;
|
||||
return s.startsWith('//') ? `https:${s}` : s;
|
||||
return { url: s.startsWith('//') ? `https:${s}` : s };
|
||||
}
|
||||
if (field && typeof field === 'object') {
|
||||
const o = field as { src?: string; file?: { url?: string } };
|
||||
const u =
|
||||
const o = field as {
|
||||
src?: string;
|
||||
file?: { url?: string };
|
||||
focal?: { x?: number; y?: number };
|
||||
alt?: string;
|
||||
};
|
||||
const rawUrl =
|
||||
typeof o.src === 'string' && o.src.trim()
|
||||
? o.src
|
||||
: typeof o.file?.url === 'string' && o.file.url.trim()
|
||||
? o.file.url
|
||||
: undefined;
|
||||
if (!u) return null;
|
||||
return u.startsWith('//') ? `https:${u}` : u;
|
||||
if (!rawUrl) return null;
|
||||
const url = rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl;
|
||||
const fx = typeof o.focal?.x === 'number' ? o.focal.x : undefined;
|
||||
const fy = typeof o.focal?.y === 'number' ? o.focal.y : undefined;
|
||||
return {
|
||||
url,
|
||||
...(fx != null && fy != null ? { focal: { x: fx, y: fy } } : {}),
|
||||
...(typeof o.alt === 'string' ? { alt: o.alt } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: nur URL. Bevorzuge extractCmsImageField für Focal/Alt.
|
||||
*/
|
||||
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
|
||||
return extractCmsImageField(field)?.url ?? null;
|
||||
}
|
||||
|
||||
export interface RustyImageTransformParams {
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -34,6 +64,14 @@ export interface RustyImageTransformParams {
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
format?: 'jpeg' | 'png' | 'webp' | 'avif';
|
||||
quality?: number;
|
||||
/** Focal-X in [0,1] (Default 0.5) — für fit:cover-Crops. */
|
||||
focalX?: number;
|
||||
/** Focal-Y in [0,1] (Default 0.5). */
|
||||
focalY?: number;
|
||||
}
|
||||
|
||||
function roundFocal(v: number): number {
|
||||
return Math.round(v * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +87,8 @@ export function getCmsImageUrl(src: string, params: RustyImageTransformParams =
|
||||
if (params.fit != null) searchParams.set('fit', params.fit);
|
||||
if (params.format != null) searchParams.set('format', params.format);
|
||||
if (params.quality != null) searchParams.set('q', String(params.quality));
|
||||
if (params.focalX != null) searchParams.set('fx', String(roundFocal(params.focalX)));
|
||||
if (params.focalY != null) searchParams.set('fy', String(roundFocal(params.focalY)));
|
||||
return `/cms-images?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
@@ -64,7 +104,7 @@ export async function ensureTransformedImage(url: string, params: RustyImageTran
|
||||
return getCmsImageUrl(url, params);
|
||||
}
|
||||
|
||||
type ImageArrayItem = string | { src?: string };
|
||||
type ImageArrayItem = string | { src?: string; focal?: { x?: number; y?: number } };
|
||||
|
||||
function toImageUrl(item: ImageArrayItem): string | null {
|
||||
if (typeof item === 'string') return item.startsWith('//') ? `https:${item}` : item;
|
||||
@@ -74,10 +114,20 @@ function toImageUrl(item: ImageArrayItem): string | null {
|
||||
}
|
||||
|
||||
export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise<string[]> {
|
||||
return items
|
||||
.map(toImageUrl)
|
||||
.filter((u): u is string => u !== null && u.startsWith('http'))
|
||||
.map((u) => getCmsImageUrl(u, params));
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const field = extractCmsImageField(item);
|
||||
if (!field || !field.url.startsWith('http')) continue;
|
||||
out.push(
|
||||
getCmsImageUrl(field.url, {
|
||||
...params,
|
||||
...(field.focal
|
||||
? { focalX: params.focalX ?? field.focal.x, focalY: params.focalY ?? field.focal.y }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RowContentLayoutLike {
|
||||
@@ -169,9 +219,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
|
||||
|
||||
if (block._type === 'image') {
|
||||
const raw = block.image ?? block.img;
|
||||
const url = absoluteUrlFromCmsImageField(raw);
|
||||
if (url) {
|
||||
block.resolvedImageSrc = getCmsImageUrl(url, transformParams);
|
||||
const field = extractCmsImageField(raw);
|
||||
if (field) {
|
||||
block.resolvedImageSrc = getCmsImageUrl(field.url, {
|
||||
...transformParams,
|
||||
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -179,9 +232,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
|
||||
if (block._type === 'image_gallery' && Array.isArray(block.images)) {
|
||||
for (const img of block.images) {
|
||||
if (!img || typeof img !== 'object') continue;
|
||||
const url = absoluteUrlFromCmsImageField(img);
|
||||
if (url) {
|
||||
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(url, galleryParams);
|
||||
const field = extractCmsImageField(img);
|
||||
if (field) {
|
||||
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(field.url, {
|
||||
...galleryParams,
|
||||
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -191,9 +247,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
|
||||
for (const org of block.organisations) {
|
||||
if (!org || typeof org !== 'object') continue;
|
||||
const o = org as { logo?: unknown; resolvedLogoSrc?: string };
|
||||
const url = absoluteUrlFromCmsImageField(o.logo);
|
||||
if (url) {
|
||||
o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams);
|
||||
const field = extractCmsImageField(o.logo);
|
||||
if (field) {
|
||||
o.resolvedLogoSrc = getCmsImageUrl(field.url, {
|
||||
...orgLogoParams,
|
||||
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user