d327798ce6
- Introduced `class-variance-authority` to manage card component styles, allowing for flexible variant and layout options. - Added a new color palette for "Fire" to the CSS for improved theming. - Updated `Card` and `PostCard` components to utilize the new styling system, enhancing visual consistency and responsiveness. - Refined layout and styling in various components for better user experience.
178 lines
6.3 KiB
Svelte
178 lines
6.3 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* 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 = [],
|
||
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) : "",
|
||
);
|
||
const subheadlineHtml = $derived(
|
||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
||
);
|
||
|
||
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 ?? "");
|
||
|
||
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;
|
||
});
|
||
});
|
||
</script>
|
||
|
||
{#if showBanner}
|
||
<div class="w-full">
|
||
{#if hasImage}
|
||
<div
|
||
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200"
|
||
style={wrapperStyle}
|
||
>
|
||
{#if hasResponsive && responsive}
|
||
{#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 transition-opacity duration-500"
|
||
class:opacity-0={!imgLoaded}
|
||
class:opacity-100={imgLoaded}
|
||
width={responsive.width}
|
||
height={responsive.height}
|
||
style={imgStyle}
|
||
loading="eager"
|
||
fetchpriority="high"
|
||
decoding="async"
|
||
bind:this={imgEl}
|
||
onload={() => (imgLoaded = true)}
|
||
/>
|
||
</picture>
|
||
{/key}
|
||
{:else}
|
||
{#key resolvedImages[0]}
|
||
<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 text-transparent transition-opacity duration-500"
|
||
class:opacity-0={!imgLoaded}
|
||
class:opacity-100={imgLoaded}
|
||
width={1920}
|
||
height={600}
|
||
style={imgStyle}
|
||
loading="eager"
|
||
fetchpriority="high"
|
||
onload={() => (imgLoaded = true)}
|
||
/>
|
||
{/key}
|
||
{/if}
|
||
{#if hasPageTitle}
|
||
<div class="absolute inset-0 flex items-center justify-start ">
|
||
<div class="container-custom mx-auto px-6 relative">
|
||
{#if headlineHtml}
|
||
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
|
||
{/if}
|
||
<div></div>
|
||
{#if subheadlineHtml}
|
||
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if hasPageTitle}
|
||
<div class="border-b border-zinc-200 container mx-auto px-6 py-4">
|
||
{#if headlineHtml}
|
||
<h1 class="text-2xl font-bold text-zinc-900">{@html headlineHtml}</h1>
|
||
{/if}
|
||
{#if subheadlineHtml}
|
||
<h2 class="text-lg text-zinc-600 mt-1">{@html subheadlineHtml}</h2>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if hasText}
|
||
<div class="w-full bg-green-700 text-white text-center py-2 px-4">
|
||
<div class="container mx-auto">
|
||
<div class="text-sm font-medium">{banner!.text}</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|