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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 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"
|
||||
>
|
||||
<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
|
||||
src={resolvedImg}
|
||||
src={fallbackImg}
|
||||
alt={post.headline ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,25 +1,137 @@
|
||||
<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 {
|
||||
/** 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),
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
class={className}
|
||||
{loading}
|
||||
/>
|
||||
<picture>
|
||||
{#each pictureSources as s (s.type)}
|
||||
<source type={s.type} srcset={s.srcset} sizes={sizesAttr} />
|
||||
{/each}
|
||||
<img
|
||||
src={fallbackUrl}
|
||||
srcset={fallbackSrcset}
|
||||
sizes={sizesAttr}
|
||||
{alt}
|
||||
width={width}
|
||||
height={computedHeight}
|
||||
class={className}
|
||||
{style}
|
||||
{loading}
|
||||
{decoding}
|
||||
{fetchpriority}
|
||||
/>
|
||||
</picture>
|
||||
|
||||
@@ -90,33 +90,37 @@
|
||||
style={wrapperStyle}
|
||||
>
|
||||
{#if hasResponsive && responsive}
|
||||
<picture>
|
||||
{#each responsive.sources as s (s.type)}
|
||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
||||
{/each}
|
||||
{#key responsive.fallback}
|
||||
<picture class="block w-full">
|
||||
{#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 text-transparent"
|
||||
width={responsive.width}
|
||||
height={responsive.height}
|
||||
style={imgStyle}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
{/key}
|
||||
{:else}
|
||||
{#key resolvedImages[0]}
|
||||
<img
|
||||
src={responsive.fallback}
|
||||
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={responsive.width}
|
||||
height={responsive.height}
|
||||
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={1920}
|
||||
height={600}
|
||||
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"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if hasPageTitle}
|
||||
<div class="absolute inset-0 flex items-center justify-start ">
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
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();
|
||||
|
||||
/** 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 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(() => {
|
||||
if (!rawSrc && 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}>
|
||||
{#if rawSrc}
|
||||
<figure class="max-w-[400px]">
|
||||
<CmsImage
|
||||
<RustyImage
|
||||
src={rawSrc}
|
||||
focal={focal}
|
||||
width={800}
|
||||
format="webp"
|
||||
quality={85}
|
||||
fit="contain"
|
||||
alt={block.caption ?? title ?? ""}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } 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();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
@@ -26,27 +27,24 @@
|
||||
const withUrl = $derived(
|
||||
images
|
||||
.map((img) => {
|
||||
const url = imageUrl(img);
|
||||
return url != null ? { img, url } : null;
|
||||
const field = extractCmsImageField(img);
|
||||
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);
|
||||
|
||||
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() {
|
||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||
}
|
||||
@@ -122,12 +120,12 @@
|
||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<CmsImage
|
||||
<RustyImage
|
||||
src={withUrl[0].url}
|
||||
focal={withUrl[0].focal}
|
||||
width={1280}
|
||||
height={720}
|
||||
aspect="16/9"
|
||||
fit="cover"
|
||||
format="webp"
|
||||
quality={80}
|
||||
alt={withUrl[0].img.description ?? ""}
|
||||
class="w-full rounded-sm object-cover aspect-video"
|
||||
@@ -158,12 +156,12 @@
|
||||
class="m-0 absolute inset-0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<CmsImage
|
||||
<RustyImage
|
||||
src={current.url}
|
||||
focal={current.focal}
|
||||
width={1280}
|
||||
height={720}
|
||||
aspect="16/9"
|
||||
fit="cover"
|
||||
format="webp"
|
||||
quality={80}
|
||||
alt={current.img.description ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
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 Badge from "$lib/components/Badge.svelte";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
/** Nach resolveContentImages: resolvedLogoSrc; sonst /cms-images Proxy. */
|
||||
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
|
||||
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 organisationLogoField(o: OrganisationEntry) {
|
||||
return extractCmsImageField(o.logo);
|
||||
}
|
||||
|
||||
function organisationLogoAlt(o: OrganisationEntry): string {
|
||||
@@ -81,17 +77,23 @@
|
||||
o.link.url.trim() !== ""
|
||||
? o.link.url.trim()
|
||||
: undefined}
|
||||
{@const logoSrc = organisationLogoSrc(o)}
|
||||
{@const logoField = organisationLogoField(o)}
|
||||
{#if compact}
|
||||
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
|
||||
<div
|
||||
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}
|
||||
<img
|
||||
src={logoSrc}
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={80}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
||||
@@ -140,11 +142,17 @@
|
||||
<div
|
||||
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}
|
||||
<img
|
||||
src={logoSrc}
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={112}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-1"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
||||
|
||||
@@ -36,8 +36,7 @@ const memoryCache = new Map<string, CachedImage>();
|
||||
let resolvedDir: string | null = null;
|
||||
|
||||
function ensureCacheDir(): string {
|
||||
if (resolvedDir) return resolvedDir;
|
||||
const dir = join(process.cwd(), CACHE_DIR);
|
||||
const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
resolvedDir = dir;
|
||||
return dir;
|
||||
|
||||
@@ -143,7 +143,7 @@ export interface ResponsiveImageOptions {
|
||||
}
|
||||
|
||||
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> = {
|
||||
avif: 'image/avif',
|
||||
webp: 'image/webp',
|
||||
@@ -232,3 +232,26 @@ export async function buildResponsiveImage(
|
||||
...(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,
|
||||
resolveImageUrls,
|
||||
} from '$lib/rusty-image';
|
||||
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||
import {
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
} from '$lib/blog-utils';
|
||||
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 {
|
||||
if (!focal) return null;
|
||||
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 ?? {};
|
||||
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
||||
try {
|
||||
@@ -76,9 +77,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
focalCss = focalToCss(primary.focal);
|
||||
responsive = await buildResponsiveImage(primary.url, {
|
||||
widths: BANNER_WIDTHS,
|
||||
fit: 'contain',
|
||||
formats: ['avif', 'webp', 'jpeg'],
|
||||
quality: 78,
|
||||
fit: 'cover',
|
||||
ar: BANNER_AR,
|
||||
quality: 72,
|
||||
focal: primary.focal,
|
||||
});
|
||||
}
|
||||
@@ -89,6 +90,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
format: 'webp',
|
||||
});
|
||||
}
|
||||
warmResponsiveImage(responsive, fetch);
|
||||
topBanner = {
|
||||
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
|
||||
resolvedImages,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveContentImages,
|
||||
resolveImageUrls,
|
||||
} from '$lib/rusty-image';
|
||||
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
|
||||
import {
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
import { PAGE_RESOLVE } from '$lib/constants';
|
||||
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 {
|
||||
if (!focal) return null;
|
||||
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 translations = locals.translations ?? {};
|
||||
|
||||
@@ -56,9 +57,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
topBannerFocalCss = focalToCss(primary.focal);
|
||||
topBannerResponsive = await buildResponsiveImage(primary.url, {
|
||||
widths: BANNER_WIDTHS,
|
||||
fit: 'contain',
|
||||
formats: ['avif', 'webp', 'jpeg'],
|
||||
quality: 78,
|
||||
fit: 'cover',
|
||||
ar: BANNER_AR,
|
||||
quality: 72,
|
||||
focal: primary.focal,
|
||||
});
|
||||
}
|
||||
@@ -68,6 +69,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
});
|
||||
warmResponsiveImage(topBannerResponsive, fetch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
return {
|
||||
post,
|
||||
postImageUrl,
|
||||
rawPostImageUrl: postImageField?.url ?? null,
|
||||
postImageFocal: postImageField?.focal ?? null,
|
||||
postDate,
|
||||
contentHtml,
|
||||
hasRowContent,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import EventBadges from '$lib/components/EventBadges.svelte';
|
||||
import EventMap from '$lib/components/EventMap.svelte';
|
||||
import Accordion from '$lib/components/Accordion.svelte';
|
||||
import RustyImage from '$lib/components/RustyImage.svelte';
|
||||
import { t, T } from '$lib/translations';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -32,7 +33,21 @@
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
<img
|
||||
src={data.postImageUrl}
|
||||
|
||||
Reference in New Issue
Block a user