feat(image): enhance image handling with focal points and responsive support
Deploy / verify (push) Failing after 14s
Deploy / deploy (push) Has been skipped

- 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:
Peter Meier
2026-04-18 00:42:33 +02:00
parent 4d79bf08da
commit a139372ce2
12 changed files with 734 additions and 61 deletions
+87 -14
View File
@@ -1,23 +1,67 @@
<script lang="ts">
/**
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown).
* TopBanner: Fullwidth-Banner mit Bild (über /cms-images aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit AVIF/WebP-Sources
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
*/
import { marked } from "marked";
import type { FullwidthBannerEntry } from "$lib/cms";
marked.setOptions({ gfm: true });
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
/** Optionaler LQIP (data-URL) als Unschärfe-Placeholder beim Laden. */
lqip?: string;
}
interface Props {
banner: FullwidthBannerEntry | null;
/** Einfache (legacy) einzelne URL(s) — wenn `responsive` fehlt. */
resolvedImages?: string[];
/** Bevorzugter Pfad: fertig berechneter `<picture>`-Inhalt fürs erste Banner-Bild. */
responsive?: ResponsiveBannerImage | null;
/** `sizes`-Attribut fürs srcset (Default: volle Viewport-Breite). */
sizes?: string;
/** Focal-Point als CSS-`object-position` (z. B. "69.5% 40.38%"). */
focalCss?: string | null;
/** Headline der Page (Markdown). */
headline?: string;
/** Subheadline der Page (Markdown). */
subheadline?: string;
}
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
let {
banner = null,
resolvedImages = [],
responsive = null,
sizes = "100vw",
focalCss = null,
headline,
subheadline,
}: Props = $props();
const imgStyle = $derived(
focalCss ? `object-position: ${focalCss};` : undefined,
);
/**
* LQIP: Blurhash-Placeholder als `background-image` auf dem Wrapper.
* Kein Blur-Filter nötig — der aus 32×32 hochskalierte JPEG ist intrinsisch weich.
* `background-position` folgt dem Focal, damit der Placeholder identisch zum Image croppt.
*/
const wrapperStyle = $derived.by(() => {
const parts: string[] = [];
if (responsive?.lqip) {
parts.push(`background-image: url("${responsive.lqip}")`);
parts.push("background-size: cover");
parts.push(`background-position: ${focalCss ?? "center"}`);
}
return parts.length > 0 ? parts.join("; ") : undefined;
});
const headlineHtml = $derived(
headline?.trim() ? (marked.parseInline(headline) as string) : "",
@@ -26,25 +70,54 @@
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
);
const hasImage = $derived(resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim()));
const hasResponsive = $derived(
responsive != null && responsive.fallback.trim().length > 0,
);
const hasImage = $derived(
hasResponsive || (resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim())),
);
const hasText = $derived(banner?.text != null && banner.text !== "");
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? "");
</script>
{#if showBanner}
<div class="w-full">
{#if hasImage}
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
<img
src={resolvedImages[0]}
alt={banner?.headline ?? headline ?? ""}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
loading="eager"
fetchpriority="high"
/>
<div
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white bg-zinc-200"
style={wrapperStyle}
>
{#if hasResponsive && responsive}
<picture>
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={responsive.width}
height={responsive.height}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
/>
</picture>
{:else}
<img
src={resolvedImages[0]}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
/>
{/if}
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
<div class="container-custom mx-auto px-6 relative">