feat(image): enhance image handling with new properties and responsive support
- Updated PostEntry type to include _rawImageUrl and _imageFocal for improved image management. - Refactored image handling in various components to utilize the new properties, ensuring better focal point support and responsive image rendering. - Introduced warmResponsiveImage function for pre-fetching image variants to optimize loading performance. - Replaced CmsImage component with RustyImage in multiple blocks for consistent image processing.
This commit is contained in:
+16
-4
@@ -258,7 +258,14 @@ export async function loadPostsList(opts: {
|
|||||||
focalX: field.focal?.x,
|
focalX: field.focal?.x,
|
||||||
focalY: field.focal?.y,
|
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 {
|
} catch {
|
||||||
/* image optional */
|
/* image optional */
|
||||||
}
|
}
|
||||||
@@ -440,9 +447,14 @@ export async function resolvePostOverviewBlocks(
|
|||||||
focalX: field.focal?.x,
|
focalX: field.focal?.x,
|
||||||
focalY: field.focal?.y,
|
focalY: field.focal?.y,
|
||||||
});
|
});
|
||||||
(
|
const p = post as PostEntry & {
|
||||||
post as PostEntry & { _resolvedImageUrl?: string }
|
_resolvedImageUrl?: string;
|
||||||
)._resolvedImageUrl = url;
|
_rawImageUrl?: string;
|
||||||
|
_imageFocal?: { x: number; y: number } | null;
|
||||||
|
};
|
||||||
|
p._resolvedImageUrl = url;
|
||||||
|
p._rawImageUrl = field.url;
|
||||||
|
p._imageFocal = field.focal ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
// Bild optional
|
// Bild optional
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getCmsImageUrl } from '$lib/rusty-image';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Asset-Dateiname (z.B. "hero.jpg") oder volle URL. */
|
|
||||||
src: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
/** Qualität 1–100 (JPEG/WebP). */
|
|
||||||
quality?: number;
|
|
||||||
format?: 'jpeg' | 'png' | 'webp' | 'avif';
|
|
||||||
fit?: 'fill' | 'contain' | 'cover';
|
|
||||||
/** Seitenverhältnis, z.B. "16:9" oder "1:1". */
|
|
||||||
ar?: string;
|
|
||||||
alt?: string;
|
|
||||||
class?: string;
|
|
||||||
loading?: 'lazy' | 'eager';
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
src,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
quality,
|
|
||||||
format,
|
|
||||||
fit,
|
|
||||||
ar,
|
|
||||||
alt = '',
|
|
||||||
class: className = '',
|
|
||||||
loading = 'lazy',
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const resolvedSrc = $derived(
|
|
||||||
getCmsImageUrl(src, { width, height, quality, format, fit, ar }),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={resolvedSrc}
|
|
||||||
{alt}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
class={className}
|
|
||||||
{loading}
|
|
||||||
/>
|
|
||||||
@@ -2,8 +2,13 @@
|
|||||||
import type { PostEntry } from "$lib/cms";
|
import type { PostEntry } from "$lib/cms";
|
||||||
import PostMeta from "./PostMeta.svelte";
|
import PostMeta from "./PostMeta.svelte";
|
||||||
import EventBadges from "./EventBadges.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 {
|
let {
|
||||||
post,
|
post,
|
||||||
@@ -15,7 +20,9 @@
|
|||||||
tagBase?: string;
|
tagBase?: string;
|
||||||
} = $props();
|
} = $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 & {
|
type PostWithEvent = PostWithImage & {
|
||||||
isEvent?: boolean;
|
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"
|
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
|
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
|
||||||
{#if resolvedImg}
|
{#if rawImg}
|
||||||
|
<RustyImage
|
||||||
|
src={rawImg}
|
||||||
|
focal={focal}
|
||||||
|
width={400}
|
||||||
|
aspect="3/2"
|
||||||
|
alt={post.headline ?? ""}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else if fallbackImg}
|
||||||
<img
|
<img
|
||||||
src={resolvedImg}
|
src={fallbackImg}
|
||||||
alt={post.headline ?? ""}
|
alt={post.headline ?? ""}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -1,25 +1,137 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…).
|
* Smart CMS-Image-Component.
|
||||||
|
* - Automatisches AVIF→WebP→JPEG `<picture>`.
|
||||||
|
* - 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 {
|
interface Props {
|
||||||
/** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */
|
/** Rohe CMS-URL (z.B. https://cms.pm86.de/api/assets/...). */
|
||||||
src: string;
|
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;
|
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;
|
alt?: string;
|
||||||
class?: string;
|
/** `sizes`-Attribut für Media-Queries; default `${width}px`. */
|
||||||
|
sizes?: string;
|
||||||
loading?: "lazy" | "eager";
|
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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
<picture>
|
||||||
{src}
|
{#each pictureSources as s (s.type)}
|
||||||
{alt}
|
<source type={s.type} srcset={s.srcset} sizes={sizesAttr} />
|
||||||
{width}
|
{/each}
|
||||||
{height}
|
<img
|
||||||
class={className}
|
src={fallbackUrl}
|
||||||
{loading}
|
srcset={fallbackSrcset}
|
||||||
/>
|
sizes={sizesAttr}
|
||||||
|
{alt}
|
||||||
|
width={width}
|
||||||
|
height={computedHeight}
|
||||||
|
class={className}
|
||||||
|
{style}
|
||||||
|
{loading}
|
||||||
|
{decoding}
|
||||||
|
{fetchpriority}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
|||||||
@@ -90,33 +90,37 @@
|
|||||||
style={wrapperStyle}
|
style={wrapperStyle}
|
||||||
>
|
>
|
||||||
{#if hasResponsive && responsive}
|
{#if hasResponsive && responsive}
|
||||||
<picture>
|
{#key responsive.fallback}
|
||||||
{#each responsive.sources as s (s.type)}
|
<picture class="block w-full">
|
||||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
{#each responsive.sources as s (s.type)}
|
||||||
{/each}
|
<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 text-transparent"
|
||||||
|
width={responsive.width}
|
||||||
|
height={responsive.height}
|
||||||
|
style={imgStyle}
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
{#key resolvedImages[0]}
|
||||||
<img
|
<img
|
||||||
src={responsive.fallback}
|
src={resolvedImages[0]}
|
||||||
alt={altText}
|
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"
|
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||||
width={responsive.width}
|
width={1920}
|
||||||
height={responsive.height}
|
height={600}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
</picture>
|
{/key}
|
||||||
{: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}
|
||||||
{#if hasPageTitle}
|
{#if hasPageTitle}
|
||||||
<div class="absolute inset-0 flex items-center justify-start ">
|
<div class="absolute inset-0 flex items-center justify-start ">
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { ImageBlockData } from "$lib/block-types";
|
import type { ImageBlockData } from "$lib/block-types";
|
||||||
import CmsImage from "$lib/components/CmsImage.svelte";
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
|
import { extractCmsImageField } from "$lib/rusty-image";
|
||||||
|
|
||||||
let { block }: { block: ImageBlockData } = $props();
|
let { block }: { block: ImageBlockData } = $props();
|
||||||
|
|
||||||
/** Rohe Bild-Quelle für /cms-images (URL, //-URL oder Asset-Dateiname/Slug). */
|
|
||||||
function getRawSrc(b: ImageBlockData): string | null {
|
|
||||||
const img = b.img ?? b.image;
|
|
||||||
if (typeof img === "string") {
|
|
||||||
const s = img.trim();
|
|
||||||
if (!s) return null;
|
|
||||||
return s.startsWith("//") ? `https:${s}` : s;
|
|
||||||
}
|
|
||||||
if (img && typeof img === "object") {
|
|
||||||
const src =
|
|
||||||
(img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
|
|
||||||
if (typeof src === "string" && src)
|
|
||||||
return src.startsWith("//") ? `https:${src}` : src;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const imageSource = $derived(block.image ?? block.img);
|
const imageSource = $derived(block.image ?? block.img);
|
||||||
const rawSrc = $derived(getRawSrc(block));
|
const imageField = $derived(extractCmsImageField(imageSource));
|
||||||
|
const rawSrc = $derived(imageField?.url ?? null);
|
||||||
|
const focal = $derived(imageField?.focal ?? null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!rawSrc && block._slug) {
|
if (!rawSrc && block._slug) {
|
||||||
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
|
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
|
||||||
@@ -57,10 +43,10 @@
|
|||||||
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
||||||
{#if rawSrc}
|
{#if rawSrc}
|
||||||
<figure class="max-w-[400px]">
|
<figure class="max-w-[400px]">
|
||||||
<CmsImage
|
<RustyImage
|
||||||
src={rawSrc}
|
src={rawSrc}
|
||||||
|
focal={focal}
|
||||||
width={800}
|
width={800}
|
||||||
format="webp"
|
|
||||||
quality={85}
|
quality={85}
|
||||||
fit="contain"
|
fit="contain"
|
||||||
alt={block.caption ?? title ?? ""}
|
alt={block.caption ?? title ?? ""}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import { useTranslate } from "$lib/translations";
|
import { useTranslate } from "$lib/translations";
|
||||||
import { T } from "$lib/translations";
|
import { T } from "$lib/translations";
|
||||||
import CmsImage from "$lib/components/CmsImage.svelte";
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
|
import { extractCmsImageField } from "$lib/rusty-image";
|
||||||
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||||
@@ -26,27 +27,24 @@
|
|||||||
const withUrl = $derived(
|
const withUrl = $derived(
|
||||||
images
|
images
|
||||||
.map((img) => {
|
.map((img) => {
|
||||||
const url = imageUrl(img);
|
const field = extractCmsImageField(img);
|
||||||
return url != null ? { img, url } : null;
|
return field?.url
|
||||||
|
? { img, url: field.url, focal: field.focal ?? null }
|
||||||
|
: null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { img: ImageGalleryImage; url: string } => x != null),
|
.filter(
|
||||||
|
(
|
||||||
|
x,
|
||||||
|
): x is {
|
||||||
|
img: ImageGalleryImage;
|
||||||
|
url: string;
|
||||||
|
focal: { x: number; y: number } | null;
|
||||||
|
} => x != null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
|
|
||||||
function imageUrl(img: ImageGalleryImage): string | null {
|
|
||||||
if (typeof img.src === "string") {
|
|
||||||
const s = img.src.trim();
|
|
||||||
if (!s) return null;
|
|
||||||
return s.startsWith("//") ? `https:${s}` : s;
|
|
||||||
}
|
|
||||||
if (img.file?.url) {
|
|
||||||
const u = img.file.url;
|
|
||||||
return u.startsWith("//") ? `https:${u}` : u;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||||
}
|
}
|
||||||
@@ -122,12 +120,12 @@
|
|||||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||||
{:else if withUrl.length === 1}
|
{:else if withUrl.length === 1}
|
||||||
<figure class="m-0">
|
<figure class="m-0">
|
||||||
<CmsImage
|
<RustyImage
|
||||||
src={withUrl[0].url}
|
src={withUrl[0].url}
|
||||||
|
focal={withUrl[0].focal}
|
||||||
width={1280}
|
width={1280}
|
||||||
height={720}
|
aspect="16/9"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
format="webp"
|
|
||||||
quality={80}
|
quality={80}
|
||||||
alt={withUrl[0].img.description ?? ""}
|
alt={withUrl[0].img.description ?? ""}
|
||||||
class="w-full rounded-sm object-cover aspect-video"
|
class="w-full rounded-sm object-cover aspect-video"
|
||||||
@@ -158,12 +156,12 @@
|
|||||||
class="m-0 absolute inset-0"
|
class="m-0 absolute inset-0"
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
>
|
>
|
||||||
<CmsImage
|
<RustyImage
|
||||||
src={current.url}
|
src={current.url}
|
||||||
|
focal={current.focal}
|
||||||
width={1280}
|
width={1280}
|
||||||
height={720}
|
aspect="16/9"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
format="webp"
|
|
||||||
quality={80}
|
quality={80}
|
||||||
alt={current.img.description ?? ""}
|
alt={current.img.description ?? ""}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
|
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
|
||||||
import { absoluteUrlFromCmsImageField, getCmsImageUrl } from "$lib/rusty-image";
|
import { extractCmsImageField } from "$lib/rusty-image";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
import Badge from "$lib/components/Badge.svelte";
|
import Badge from "$lib/components/Badge.svelte";
|
||||||
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
marked.setOptions({ gfm: true });
|
||||||
|
|
||||||
/** Nach resolveContentImages: resolvedLogoSrc; sonst /cms-images Proxy. */
|
function organisationLogoField(o: OrganisationEntry) {
|
||||||
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
|
return extractCmsImageField(o.logo);
|
||||||
const resolved = o.resolvedLogoSrc?.trim();
|
|
||||||
if (resolved) return resolved;
|
|
||||||
const url = absoluteUrlFromCmsImageField(o.logo);
|
|
||||||
if (!url) return undefined;
|
|
||||||
return getCmsImageUrl(url, { width: 256, fit: 'contain', format: 'webp', quality: 85 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function organisationLogoAlt(o: OrganisationEntry): string {
|
function organisationLogoAlt(o: OrganisationEntry): string {
|
||||||
@@ -81,17 +77,23 @@
|
|||||||
o.link.url.trim() !== ""
|
o.link.url.trim() !== ""
|
||||||
? o.link.url.trim()
|
? o.link.url.trim()
|
||||||
: undefined}
|
: undefined}
|
||||||
{@const logoSrc = organisationLogoSrc(o)}
|
{@const logoField = organisationLogoField(o)}
|
||||||
{#if compact}
|
{#if compact}
|
||||||
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
|
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
|
||||||
<div
|
<div
|
||||||
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
||||||
>
|
>
|
||||||
{#if logoSrc}
|
{#if logoField}
|
||||||
<img
|
<RustyImage
|
||||||
src={logoSrc}
|
src={logoField.url}
|
||||||
|
focal={logoField.focal ?? null}
|
||||||
|
width={80}
|
||||||
|
aspect="1/1"
|
||||||
|
fit="contain"
|
||||||
|
quality={85}
|
||||||
alt={organisationLogoAlt(o)}
|
alt={organisationLogoAlt(o)}
|
||||||
class="h-full w-full object-contain p-0.5"
|
class="h-full w-full object-contain p-0.5"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
||||||
@@ -140,11 +142,17 @@
|
|||||||
<div
|
<div
|
||||||
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
||||||
>
|
>
|
||||||
{#if logoSrc}
|
{#if logoField}
|
||||||
<img
|
<RustyImage
|
||||||
src={logoSrc}
|
src={logoField.url}
|
||||||
|
focal={logoField.focal ?? null}
|
||||||
|
width={112}
|
||||||
|
aspect="1/1"
|
||||||
|
fit="contain"
|
||||||
|
quality={85}
|
||||||
alt={organisationLogoAlt(o)}
|
alt={organisationLogoAlt(o)}
|
||||||
class="h-full w-full object-contain p-1"
|
class="h-full w-full object-contain p-1"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ const memoryCache = new Map<string, CachedImage>();
|
|||||||
let resolvedDir: string | null = null;
|
let resolvedDir: string | null = null;
|
||||||
|
|
||||||
function ensureCacheDir(): string {
|
function ensureCacheDir(): string {
|
||||||
if (resolvedDir) return resolvedDir;
|
const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR);
|
||||||
const dir = join(process.cwd(), CACHE_DIR);
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
resolvedDir = dir;
|
resolvedDir = dir;
|
||||||
return dir;
|
return dir;
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export interface ResponsiveImageOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
|
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
|
||||||
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['avif', 'webp', 'jpeg'];
|
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg'];
|
||||||
const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = {
|
const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = {
|
||||||
avif: 'image/avif',
|
avif: 'image/avif',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
@@ -232,3 +232,26 @@ export async function buildResponsiveImage(
|
|||||||
...(lqip ? { lqip } : {}),
|
...(lqip ? { lqip } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget Cache-Warmup: alle srcset-Varianten + Fallback parallel anrequesten.
|
||||||
|
* Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR).
|
||||||
|
* Blockiert den Response NICHT — läuft im Hintergrund weiter.
|
||||||
|
*/
|
||||||
|
export function warmResponsiveImage(
|
||||||
|
responsive: ResponsiveImage | null,
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
): void {
|
||||||
|
if (!responsive) return;
|
||||||
|
const urls = new Set<string>();
|
||||||
|
urls.add(responsive.fallback);
|
||||||
|
for (const s of responsive.sources) {
|
||||||
|
for (const entry of s.srcset.split(',')) {
|
||||||
|
const url = entry.trim().split(/\s+/)[0];
|
||||||
|
if (url) urls.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const u of urls) {
|
||||||
|
void fetchFn(u).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveContentImages,
|
resolveContentImages,
|
||||||
resolveImageUrls,
|
resolveImageUrls,
|
||||||
} from '$lib/rusty-image';
|
} from '$lib/rusty-image';
|
||||||
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||||
import {
|
import {
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
@@ -13,14 +13,15 @@ import {
|
|||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
||||||
|
|
||||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
|
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||||
|
const BANNER_AR = '16/9';
|
||||||
|
|
||||||
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
||||||
if (!focal) return null;
|
if (!focal) return null;
|
||||||
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
||||||
try {
|
try {
|
||||||
@@ -76,9 +77,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
focalCss = focalToCss(primary.focal);
|
focalCss = focalToCss(primary.focal);
|
||||||
responsive = await buildResponsiveImage(primary.url, {
|
responsive = await buildResponsiveImage(primary.url, {
|
||||||
widths: BANNER_WIDTHS,
|
widths: BANNER_WIDTHS,
|
||||||
fit: 'contain',
|
fit: 'cover',
|
||||||
formats: ['avif', 'webp', 'jpeg'],
|
ar: BANNER_AR,
|
||||||
quality: 78,
|
quality: 72,
|
||||||
focal: primary.focal,
|
focal: primary.focal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
format: 'webp',
|
format: 'webp',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
warmResponsiveImage(responsive, fetch);
|
||||||
topBanner = {
|
topBanner = {
|
||||||
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
|
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
|
||||||
resolvedImages,
|
resolvedImages,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveContentImages,
|
resolveContentImages,
|
||||||
resolveImageUrls,
|
resolveImageUrls,
|
||||||
} from '$lib/rusty-image';
|
} from '$lib/rusty-image';
|
||||||
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||||
import {
|
import {
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
@@ -14,14 +14,15 @@ import {
|
|||||||
import { PAGE_RESOLVE } from '$lib/constants';
|
import { PAGE_RESOLVE } from '$lib/constants';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
|
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||||
|
const BANNER_AR = '16/9';
|
||||||
|
|
||||||
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
||||||
if (!focal) return null;
|
if (!focal) return null;
|
||||||
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
|
|
||||||
@@ -56,9 +57,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
topBannerFocalCss = focalToCss(primary.focal);
|
topBannerFocalCss = focalToCss(primary.focal);
|
||||||
topBannerResponsive = await buildResponsiveImage(primary.url, {
|
topBannerResponsive = await buildResponsiveImage(primary.url, {
|
||||||
widths: BANNER_WIDTHS,
|
widths: BANNER_WIDTHS,
|
||||||
fit: 'contain',
|
fit: 'cover',
|
||||||
formats: ['avif', 'webp', 'jpeg'],
|
ar: BANNER_AR,
|
||||||
quality: 78,
|
quality: 72,
|
||||||
focal: primary.focal,
|
focal: primary.focal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
fit: 'cover',
|
fit: 'cover',
|
||||||
format: 'webp',
|
format: 'webp',
|
||||||
});
|
});
|
||||||
|
warmResponsiveImage(topBannerResponsive, fetch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
postImageUrl,
|
postImageUrl,
|
||||||
|
rawPostImageUrl: postImageField?.url ?? null,
|
||||||
|
postImageFocal: postImageField?.focal ?? null,
|
||||||
postDate,
|
postDate,
|
||||||
contentHtml,
|
contentHtml,
|
||||||
hasRowContent,
|
hasRowContent,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import EventBadges from '$lib/components/EventBadges.svelte';
|
import EventBadges from '$lib/components/EventBadges.svelte';
|
||||||
import EventMap from '$lib/components/EventMap.svelte';
|
import EventMap from '$lib/components/EventMap.svelte';
|
||||||
import Accordion from '$lib/components/Accordion.svelte';
|
import Accordion from '$lib/components/Accordion.svelte';
|
||||||
|
import RustyImage from '$lib/components/RustyImage.svelte';
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from '$lib/translations';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -32,7 +33,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="md:flex gap-2 items-end">
|
<div class="md:flex gap-2 items-end">
|
||||||
{#if data.postImageUrl}
|
{#if data.rawPostImageUrl}
|
||||||
|
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
||||||
|
<RustyImage
|
||||||
|
src={data.rawPostImageUrl}
|
||||||
|
focal={data.postImageFocal}
|
||||||
|
width={150}
|
||||||
|
height={200}
|
||||||
|
fit="cover"
|
||||||
|
alt=""
|
||||||
|
class="block object-cover"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if data.postImageUrl}
|
||||||
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
||||||
<img
|
<img
|
||||||
src={data.postImageUrl}
|
src={data.postImageUrl}
|
||||||
|
|||||||
Reference in New Issue
Block a user