diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index d9567a6..fe158d2 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -258,7 +258,14 @@ export async function loadPostsList(opts: { focalX: field.focal?.x, focalY: field.focal?.y, }); - (post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url; + const p = post as PostEntry & { + _resolvedImageUrl?: string; + _rawImageUrl?: string; + _imageFocal?: { x: number; y: number } | null; + }; + p._resolvedImageUrl = url; + p._rawImageUrl = field.url; + p._imageFocal = field.focal ?? null; } catch { /* image optional */ } @@ -440,9 +447,14 @@ export async function resolvePostOverviewBlocks( focalX: field.focal?.x, focalY: field.focal?.y, }); - ( - post as PostEntry & { _resolvedImageUrl?: string } - )._resolvedImageUrl = url; + const p = post as PostEntry & { + _resolvedImageUrl?: string; + _rawImageUrl?: string; + _imageFocal?: { x: number; y: number } | null; + }; + p._resolvedImageUrl = url; + p._rawImageUrl = field.url; + p._imageFocal = field.focal ?? null; } catch { // Bild optional } diff --git a/src/lib/components/CmsImage.svelte b/src/lib/components/CmsImage.svelte deleted file mode 100644 index 1619945..0000000 --- a/src/lib/components/CmsImage.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/src/lib/components/PostCard.svelte b/src/lib/components/PostCard.svelte index a6f860b..8bd6e05 100644 --- a/src/lib/components/PostCard.svelte +++ b/src/lib/components/PostCard.svelte @@ -2,8 +2,13 @@ import type { PostEntry } from "$lib/cms"; import PostMeta from "./PostMeta.svelte"; import EventBadges from "./EventBadges.svelte"; + import RustyImage from "./RustyImage.svelte"; - type PostWithImage = PostEntry & { _resolvedImageUrl?: string }; + type PostWithImage = PostEntry & { + _resolvedImageUrl?: string; + _rawImageUrl?: string; + _imageFocal?: { x: number; y: number } | null; + }; let { post, @@ -15,7 +20,9 @@ tagBase?: string; } = $props(); - const resolvedImg = $derived(post._resolvedImageUrl); + const rawImg = $derived(post._rawImageUrl); + const focal = $derived(post._imageFocal ?? null); + const fallbackImg = $derived(post._resolvedImageUrl); type PostWithEvent = PostWithImage & { isEvent?: boolean; @@ -33,9 +40,19 @@ class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white" >
- {#if resolvedImg} + {#if rawImg} + + {:else if fallbackImg} {post.headline /** - * Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…). + * Smart CMS-Image-Component. + * - Automatisches AVIF→WebP→JPEG ``. + * - Retina-srcset per `densities` (Default [1, 2]). + * - CLS=0 via `width`+`height` (oder aus `aspect` abgeleitet). + * - Focal durchgereicht (cover-Crop am Motiv). + * + * `src` = rohe CMS-URL (ohne /cms-images-Wrapper). Die Component baut + * die transformierten /cms-images?…-URLs selbst. */ + import { getCmsImageUrl, type RustyImageTransformParams } from "$lib/rusty-image"; + interface Props { - /** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */ + /** Rohe CMS-URL (z.B. https://cms.pm86.de/api/assets/...). */ src: string; - width?: number; + /** Ziel-Renderbreite in CSS-px (für Attribut + srcset-Basis). */ + width: number; + /** Ziel-Renderhöhe; alternativ `aspect`. Mindestens eins muss gegeben sein. */ height?: number; + /** Seitenverhältnis, z.B. "16/9", "3/2". Alternativ zu `height`. */ + aspect?: string; + /** Focal-Point aus CMS-Image-Field. */ + focal?: { x: number; y: number } | null; alt?: string; - class?: string; + /** `sizes`-Attribut für Media-Queries; default `${width}px`. */ + sizes?: string; loading?: "lazy" | "eager"; + fetchpriority?: "high" | "auto" | "low"; + decoding?: "async" | "sync" | "auto"; + class?: string; + style?: string; + /** Pixel-Dichten fürs srcset. Default [1, 2] (Retina). */ + densities?: number[]; + /** Format-Fallback-Kette. Default ['avif','webp','jpeg']. */ + formats?: ("avif" | "webp" | "jpeg")[]; + /** JPEG/AVIF/WebP-Qualität. Default 75. */ + quality?: number; + fit?: "cover" | "contain" | "fill"; } - let { src, width, height, alt = "", class: className = "", loading = "lazy" }: Props = $props(); + let { + src, + width, + height, + aspect, + focal = null, + alt = "", + sizes, + loading = "lazy", + fetchpriority, + decoding = "async", + class: className = "", + style, + densities = [1, 2], + formats = ["webp", "jpeg"], + quality = 75, + fit = "cover", + }: Props = $props(); + + function parseAr(s: string | undefined): number | null { + if (!s) return null; + const m = s.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/); + if (!m) return null; + const w = Number(m[1]); + const h = Number(m[2]); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; + return w / h; + } + + const arValue = $derived(parseAr(aspect)); + const computedHeight = $derived( + height ?? (arValue ? Math.round(width / arValue) : undefined), + ); + const arString = $derived( + aspect ?? + (height && width ? `${width}/${height}` : undefined), + ); + const sizesAttr = $derived(sizes ?? `${width}px`); + + function baseParams(format: "avif" | "webp" | "jpeg", w: number): RustyImageTransformParams { + return { + width: w, + format, + fit, + quality, + ...(arString ? { ar: arString } : {}), + ...(focal ? { focalX: focal.x, focalY: focal.y } : {}), + ...(arString && fit !== "contain" + ? { height: Math.round(w / (arValue ?? 1)) } + : {}), + }; + } + + function buildSrcset(format: "avif" | "webp" | "jpeg"): string { + return densities + .map((d) => { + const w = Math.round(width * d); + return `${getCmsImageUrl(src, baseParams(format, w))} ${d}x`; + }) + .join(", "); + } + + const fallbackFormat = $derived<"avif" | "webp" | "jpeg">( + formats.includes("jpeg") ? "jpeg" : formats[formats.length - 1] ?? "jpeg", + ); + const fallbackUrl = $derived( + getCmsImageUrl(src, baseParams(fallbackFormat, width)), + ); + const fallbackSrcset = $derived(buildSrcset(fallbackFormat)); + + const pictureSources = $derived( + formats.filter((f) => f !== fallbackFormat).map((f) => ({ + type: `image/${f}`, + srcset: buildSrcset(f), + })), + ); - + + {#each pictureSources as s (s.type)} + + {/each} + + diff --git a/src/lib/components/TopBanner.svelte b/src/lib/components/TopBanner.svelte index 8f06e8b..cba9cff 100644 --- a/src/lib/components/TopBanner.svelte +++ b/src/lib/components/TopBanner.svelte @@ -90,33 +90,37 @@ style={wrapperStyle} > {#if hasResponsive && responsive} - - {#each responsive.sources as s (s.type)} - - {/each} + {#key responsive.fallback} + + {#each responsive.sources as s (s.type)} + + {/each} + {altText} + + {/key} + {:else} + {#key resolvedImages[0]} {altText} - - {:else} - {altText} + {/key} {/if} {#if hasPageTitle}
diff --git a/src/lib/components/blocks/ImageBlock.svelte b/src/lib/components/blocks/ImageBlock.svelte index b81990f..c946b9e 100644 --- a/src/lib/components/blocks/ImageBlock.svelte +++ b/src/lib/components/blocks/ImageBlock.svelte @@ -1,30 +1,16 @@