perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
- TopBanner: JS-Fade-Gate entfernt (opacity-0 bis Hydration schob LCP von ~3s auf ~8.6s) — Bild malt jetzt progressiv über den LQIP - buildBannerResponsive: Breakpoint-Varianten (1/1, 16/9, 24/9) mit media-Attributen, quality 60; CMS braucht explizites h neben ar, sonst kein Crop (Mobile bekam 1.92:1 unscharf hochskaliert) - Layout-Preload: ein Link pro Breakpoint-Variante (WebP) - Layout-Bootstrap-Batch + Logo-SVG-Fetch TTL-gecacht (liefen pro SSR-Request); invalidateCollection versteht CSV-Key-Heads - WindkarteBlock: 40 Einzel-Fetches -> 1 List-Request mit Filter - app.html: preconnect cms.pm86.de, api.iconify.design, analytics - u-url Microformat: echte canonicalUrl statt href="#" (SEO-Audit) - Icon-Subset: 'cloud' ergänzt (kein Runtime-Fetch an iconify mehr) - StrommixBlock: nutzloses transition-colors entfernt (CLS-Audit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||
|
||||
interface ResponsiveBannerImage {
|
||||
sources: { type: string; srcset: string }[];
|
||||
sources: { type: string; srcset: string; media?: string }[];
|
||||
fallback: string;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -79,18 +79,9 @@
|
||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||
|
||||
let imgEl = $state<HTMLImageElement | null>(null);
|
||||
let imgLoaded = $state(false);
|
||||
|
||||
const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? "");
|
||||
$effect(() => {
|
||||
// reset on src change, then probe cache
|
||||
void currentSrc;
|
||||
imgLoaded = false;
|
||||
queueMicrotask(() => {
|
||||
if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true;
|
||||
});
|
||||
});
|
||||
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
|
||||
// nach der Hydration unsichtbar und schob den LCP von ~3s auf ~8s. Das Bild
|
||||
// malt jetzt progressiv über den LQIP-Background — sichtbar ab erstem Paint.
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
@@ -103,23 +94,19 @@
|
||||
{#if hasResponsive && responsive}
|
||||
{#key responsive.fallback}
|
||||
<picture class="block w-full max-w-[1920px] mx-auto">
|
||||
{#each responsive.sources as s (s.type)}
|
||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
||||
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
|
||||
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
|
||||
{/each}
|
||||
<img
|
||||
src={responsive.fallback}
|
||||
alt={altText}
|
||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||
class:opacity-0={!imgLoaded}
|
||||
class:opacity-100={imgLoaded}
|
||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] 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"
|
||||
bind:this={imgEl}
|
||||
onload={() => (imgLoaded = true)}
|
||||
/>
|
||||
</picture>
|
||||
{/key}
|
||||
@@ -128,15 +115,12 @@
|
||||
<img
|
||||
src={resolvedImages[0]}
|
||||
alt={altText}
|
||||
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||
class:opacity-0={!imgLoaded}
|
||||
class:opacity-100={imgLoaded}
|
||||
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||
width={1920}
|
||||
height={600}
|
||||
style={imgStyle}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
onload={() => (imgLoaded = true)}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
<svelte:element
|
||||
this={explanationHref ? "a" : "div"}
|
||||
href={explanationHref || undefined}
|
||||
class="block rounded-xs border border-l-4 bg-white shadow-sm transition-colors {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
|
||||
class="block rounded-xs border border-l-4 bg-white shadow-sm {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
|
||||
{#if !live}
|
||||
|
||||
@@ -69,22 +69,17 @@ let areas = $state<WindArea[]>([]);
|
||||
.map((a) => (typeof a === "string" ? a : a._slug))
|
||||
.filter(Boolean);
|
||||
|
||||
if (slugs.length > 0) {
|
||||
const results = await Promise.all(
|
||||
slugs.map((slug) =>
|
||||
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
|
||||
signal: AbortSignal.timeout(6_000),
|
||||
}).then((r) => (r.ok ? r.json() : null))
|
||||
)
|
||||
);
|
||||
areas = results.filter((r): r is WindArea => r !== null);
|
||||
} else {
|
||||
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=100`, {
|
||||
signal: AbortSignal.timeout(6_000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
areas = data.items ?? [];
|
||||
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=200`, {
|
||||
signal: AbortSignal.timeout(6_000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const allAreas: WindArea[] = data.items ?? [];
|
||||
if (slugs.length > 0) {
|
||||
const slugSet = new Set(slugs);
|
||||
areas = allAreas.filter((a) => slugSet.has(a._slug));
|
||||
} else {
|
||||
areas = allAreas;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user