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}

/**
- * 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}
+
+
+ {/key}
+ {:else}
+ {#key resolvedImages[0]}
-
- {:else}
-
+ {/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 @@