Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
|
||||
let {
|
||||
label,
|
||||
open = false,
|
||||
id = undefined,
|
||||
class: cls = "",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<details {id} {open} class="pt-4 border-t border-zinc-200 group {cls}">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-medium select-none">
|
||||
<Icon icon="mdi:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
|
||||
{label}
|
||||
</summary>
|
||||
<div class="mt-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
color,
|
||||
class: className = "",
|
||||
}: { children?: Snippet; color?: string; class?: string } = $props();
|
||||
|
||||
function colorClasses(c?: string): string {
|
||||
switch (c) {
|
||||
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
|
||||
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
|
||||
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
|
||||
default: return "bg-stein-100 text-stein-600 border border-stein-200";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {colorClasses(color)} {className}">
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import Tag from "./Tag.svelte";
|
||||
import Pagination from "./Pagination.svelte";
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import { postHref } from "$lib/blog-utils";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
interface TagEntry {
|
||||
_slug?: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
type SearchIndexEntry = {
|
||||
slug: string;
|
||||
headline: string;
|
||||
excerpt: string;
|
||||
subheadline: string;
|
||||
tags: string[];
|
||||
image: string | null;
|
||||
created: string | null;
|
||||
isEvent: boolean;
|
||||
eventDate: string | null;
|
||||
};
|
||||
|
||||
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
|
||||
|
||||
let {
|
||||
posts = [],
|
||||
tags = [],
|
||||
activeTag = null,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
totalPosts = 0,
|
||||
basePath = "/posts",
|
||||
translations = {},
|
||||
upcomingEvents = [],
|
||||
searchIndex = null,
|
||||
}: {
|
||||
posts: PostEntry[];
|
||||
tags: TagEntry[];
|
||||
activeTag: string | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalPosts?: number;
|
||||
basePath?: string;
|
||||
translations?: Translations | null;
|
||||
upcomingEvents?: EventPost[];
|
||||
searchIndex?: SearchIndexEntry[] | null;
|
||||
} = $props();
|
||||
|
||||
let query = $state("");
|
||||
let year = $state<string>("");
|
||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
||||
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
||||
|
||||
function yearOf(entry: SearchIndexEntry): string {
|
||||
const iso = entry.created ?? "";
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "" : String(d.getFullYear());
|
||||
}
|
||||
|
||||
const availableYears = $derived.by<string[]>(() => {
|
||||
if (!searchIndex) return [];
|
||||
const set = new Set<string>();
|
||||
for (const p of searchIndex) {
|
||||
const y = yearOf(p);
|
||||
if (y) set.add(y);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => b.localeCompare(a));
|
||||
});
|
||||
|
||||
const filteredResults = $derived.by<SearchIndexEntry[]>(() => {
|
||||
if (!isFiltering || !searchIndex) return [];
|
||||
const q = normalizedQuery;
|
||||
return searchIndex
|
||||
.filter((p) => {
|
||||
if (year && yearOf(p) !== year) return false;
|
||||
if (q) {
|
||||
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, 80);
|
||||
});
|
||||
|
||||
function searchResultHref(slug: string): string {
|
||||
const norm = (slug ?? "").replace(/^\//, "").trim();
|
||||
return norm ? `/post/${encodeURIComponent(norm)}` : "/post";
|
||||
}
|
||||
|
||||
function formatEventDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
|
||||
function tagHref(tagSlug: string | null): string {
|
||||
if (!tagSlug) return basePath;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
|
||||
}
|
||||
|
||||
const totalCount = $derived(totalPosts);
|
||||
</script>
|
||||
|
||||
<div class="blog-overview">
|
||||
{#if searchIndex}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<label class="relative block w-full sm:max-w-sm">
|
||||
<span class="sr-only">{t(T.blog_search_label)}</span>
|
||||
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
|
||||
<Icon icon="mdi:magnify" class="h-4 w-4" />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t(T.blog_search_placeholder)}
|
||||
class="w-full rounded-sm border border-zinc-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (query = "")}
|
||||
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
|
||||
aria-label={t(T.blog_search_clear)}
|
||||
>
|
||||
<Icon icon="mdi:close" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
{#if availableYears.length > 0}
|
||||
<label class="relative inline-flex items-center">
|
||||
<span class="sr-only">{t(T.blog_year_label)}</span>
|
||||
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
|
||||
<Icon icon="mdi:calendar" class="h-4 w-4" />
|
||||
</span>
|
||||
<select
|
||||
bind:value={year}
|
||||
class="appearance-none rounded-sm border border-zinc-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
|
||||
>
|
||||
<option value="">{t(T.blog_year_all)}</option>
|
||||
{#each availableYears as y}
|
||||
<option value={y}>{y}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-zinc-500">
|
||||
<Icon icon="mdi:chevron-down" class="h-4 w-4" />
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isFiltering && upcomingEvents.length > 0}
|
||||
<section class="mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4">
|
||||
<h2 class="mb-3 flex items-center gap-1.5 text-sm font-semibold tracking-wide text-zinc-700 uppercase">
|
||||
<Icon icon="mdi:calendar-clock" class="h-4 w-4" />
|
||||
{t(T.blog_upcoming_events)}
|
||||
</h2>
|
||||
<ul class="space-y-2">
|
||||
{#each upcomingEvents as ev}
|
||||
<li>
|
||||
<a href={postHref(ev)} class="group flex items-baseline gap-3 no-underline hover:underline">
|
||||
<time class="shrink-0 text-xs font-medium text-zinc-600 tabular-nums">
|
||||
{formatEventDate(ev.eventDate)}
|
||||
</time>
|
||||
<span class="text-sm text-zinc-900 group-hover:text-zinc-950">{ev.headline}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if isFiltering}
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600">
|
||||
<span>
|
||||
{t(T.blog_results_count, { count: filteredResults.length })}
|
||||
{#if query}{t(T.blog_results_for, { query })}{/if}
|
||||
{#if year}{t(T.blog_results_in_year, { year })}{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { query = ""; year = ""; }}
|
||||
class="text-zinc-500 underline hover:text-zinc-900"
|
||||
>{t(T.blog_filter_reset)}</button>
|
||||
</div>
|
||||
{#if filteredResults.length > 0}
|
||||
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredResults as hit}
|
||||
<a href={searchResultHref(hit.slug)} class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all">
|
||||
{#if hit.image}
|
||||
<div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
|
||||
<img src={hit.image} alt={hit.headline} class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<h3 class="mb-1 text-base font-semibold">{hit.headline}</h3>
|
||||
{#if hit.excerpt}
|
||||
<p class="text-sm text-zinc-700 line-clamp-3">{hit.excerpt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
|
||||
{t(T.blog_no_results)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
|
||||
<Tag
|
||||
label={t(T.blog_tag_all)}
|
||||
href={tagHref(null)}
|
||||
variant={activeTag ? "blue" : "green"}
|
||||
active={!activeTag}
|
||||
/>
|
||||
{#each tags as tag}
|
||||
{@const slug = tag._slug ?? ""}
|
||||
<Tag
|
||||
label={tag.name}
|
||||
href={tagHref(slug)}
|
||||
variant={activeTag === slug ? "inactive" : "green"}
|
||||
active={activeTag === slug}
|
||||
icon={tag.icon?.trim() || null}
|
||||
customColor={activeTag === slug ? null : tag.color?.trim() || null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Pagination
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{basePath}
|
||||
activeTag={activeTag}
|
||||
/>
|
||||
|
||||
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each posts as post}
|
||||
<PostCard
|
||||
post={post}
|
||||
href={postHref(post)}
|
||||
tagBase={`${basePath}/tag`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Pagination
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{basePath}
|
||||
activeTag={activeTag}
|
||||
/>
|
||||
<div class="grow"></div>
|
||||
<div class="bg-white rounded-md font-thin">
|
||||
<div class="inline-flex text-xs p-2">
|
||||
{#if totalCount > 0}
|
||||
{t(T.blog_count, {
|
||||
visible: posts.length,
|
||||
total: totalCount,
|
||||
current: currentPage,
|
||||
totalPages,
|
||||
})}
|
||||
{:else}
|
||||
{t(T.blog_count, {
|
||||
visible: 0,
|
||||
total: 0,
|
||||
current: currentPage,
|
||||
totalPages,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-zinc-600">
|
||||
<a href="/posts/rss.xml" class="inline-flex items-center gap-1 hover:underline">
|
||||
<Icon icon="mdi:rss" class="h-3.5 w-3.5" />
|
||||
{t(T.blog_rss_subscribe)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
class: className = "",
|
||||
}: {
|
||||
children?: Snippet;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm min-w-0 overflow-hidden";
|
||||
const interactive = $derived(
|
||||
href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "",
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} {rel} class="{base} {interactive} {className}">
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<div class="{base} {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,45 @@
|
||||
<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}
|
||||
/>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
|
||||
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
|
||||
import HtmlBlock from "./blocks/HtmlBlock.svelte";
|
||||
import IframeBlock from "./blocks/IframeBlock.svelte";
|
||||
import ImageBlock from "./blocks/ImageBlock.svelte";
|
||||
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
|
||||
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
||||
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
||||
import LinkListBlock from "./blocks/LinkListBlock.svelte";
|
||||
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
|
||||
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
|
||||
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
||||
import type { BlockLayout } from "$lib/block-layout";
|
||||
import type { Translations } from "$lib/translations";
|
||||
import type {
|
||||
RowContentLayout,
|
||||
ResolvedBlock,
|
||||
MarkdownBlockData,
|
||||
HeadlineBlockData,
|
||||
HtmlBlockData,
|
||||
IframeBlockData,
|
||||
ImageBlockData,
|
||||
ImageGalleryBlockData,
|
||||
YoutubeVideoBlockData,
|
||||
QuoteBlockData,
|
||||
LinkListBlockData,
|
||||
PostOverviewBlockData,
|
||||
SearchableTextBlockData,
|
||||
CalendarBlockData,
|
||||
OrganisationsBlockData,
|
||||
OpnFormBlockData,
|
||||
} from "$lib/block-types";
|
||||
|
||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||
|
||||
function isResolvedBlock(item: unknown): item is ResolvedBlock {
|
||||
return typeof item === "object" && item !== null;
|
||||
}
|
||||
|
||||
function blockType(item: ResolvedBlock): string {
|
||||
return (item._type as string) ?? "unknown";
|
||||
}
|
||||
|
||||
function justifyToClass(j?: string): string {
|
||||
switch (j) {
|
||||
case "end": return "justify-end";
|
||||
case "center": return "justify-center";
|
||||
case "between": return "justify-between";
|
||||
case "around": return "justify-around";
|
||||
case "evenly": return "justify-evenly";
|
||||
default: return "justify-start";
|
||||
}
|
||||
}
|
||||
|
||||
function alignToClass(a?: string): string {
|
||||
switch (a) {
|
||||
case "end": return "items-end";
|
||||
case "center": return "items-center";
|
||||
case "baseline": return "items-baseline";
|
||||
case "stretch": return "items-stretch";
|
||||
default: return "items-start";
|
||||
}
|
||||
}
|
||||
|
||||
const rows = $derived([
|
||||
{
|
||||
content: layout.row1Content ?? [],
|
||||
justify: justifyToClass(layout.row1JustifyContent),
|
||||
align: alignToClass(layout.row1AlignItems),
|
||||
},
|
||||
{
|
||||
content: layout.row2Content ?? [],
|
||||
justify: justifyToClass(layout.row2JustifyContent),
|
||||
align: alignToClass(layout.row2AlignItems),
|
||||
},
|
||||
{
|
||||
content: layout.row3Content ?? [],
|
||||
justify: justifyToClass(layout.row3JustifyContent),
|
||||
align: alignToClass(layout.row3AlignItems),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="content my-6 {className}">
|
||||
{#each rows as row}
|
||||
{#if row.content.length > 0}
|
||||
<div
|
||||
class="content-row grid grid-cols-12 gap-4 {row.justify} {row.align}"
|
||||
role="region"
|
||||
data-cf-type="grid-row"
|
||||
>
|
||||
{#each row.content as item}
|
||||
{#if isResolvedBlock(item)}
|
||||
{#snippet blockContent()}
|
||||
{#if blockType(item) === "markdown"}
|
||||
<MarkdownBlock block={item as MarkdownBlockData} />
|
||||
{:else if blockType(item) === "headline"}
|
||||
<HeadlineBlock block={item as HeadlineBlockData} />
|
||||
{:else if blockType(item) === "html"}
|
||||
<HtmlBlock block={item as HtmlBlockData} />
|
||||
{:else if blockType(item) === "iframe"}
|
||||
<IframeBlock block={item as IframeBlockData} translations={translations} />
|
||||
{:else if blockType(item) === "image"}
|
||||
<ImageBlock block={item as ImageBlockData} />
|
||||
{:else if blockType(item) === "image_gallery"}
|
||||
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
|
||||
{:else if blockType(item) === "youtube_video"}
|
||||
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
|
||||
{:else if blockType(item) === "quote"}
|
||||
<QuoteBlock block={item as QuoteBlockData} />
|
||||
{:else if blockType(item) === "link_list"}
|
||||
<LinkListBlock block={item as LinkListBlockData} />
|
||||
{:else if blockType(item) === "post_overview"}
|
||||
<PostOverviewBlock block={item as PostOverviewBlockData} />
|
||||
{:else if blockType(item) === "searchable_text"}
|
||||
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
|
||||
{:else if blockType(item) === "calendar"}
|
||||
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
|
||||
{:else if blockType(item) === "organisations"}
|
||||
<OrganisationsBlock block={item as OrganisationsBlockData} />
|
||||
{:else if blockType(item) === "opnform"}
|
||||
<OpnFormBlock block={item as OpnFormBlockData} />
|
||||
{:else}
|
||||
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Unbekannter Block: <code>{blockType(item)}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
|
||||
{@const rowBlockType = blockType(item)}
|
||||
<div class="col-span-12">
|
||||
<div
|
||||
class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote'
|
||||
? 'bg-wald-50'
|
||||
: '[background:var(--color-container-breakout)]'}"
|
||||
>
|
||||
<div class="container-custom py-8">
|
||||
{@render blockContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render blockContent()}
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
|
||||
let {
|
||||
eventDate = null,
|
||||
locationText = null,
|
||||
locationHref = null,
|
||||
}: {
|
||||
eventDate?: string | null;
|
||||
locationText?: string | null;
|
||||
locationHref?: string | null;
|
||||
} = $props();
|
||||
|
||||
function formatEventDate(dateStr: string): string | null {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("de-DE", {
|
||||
weekday: "short", day: "2-digit", month: "short",
|
||||
year: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const dateLabel = $derived(eventDate ? formatEventDate(eventDate) : null);
|
||||
</script>
|
||||
|
||||
{#if dateLabel || locationText}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if dateLabel}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-wald-500 px-3 py-1 text-[.65rem] font-semibold text-white shadow-sm">
|
||||
<Icon icon="mdi:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if locationText}
|
||||
{#if locationHref}
|
||||
<a href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-neutral-700 transition-colors no-underline!">
|
||||
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{locationText}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50 px-3 py-1 text-[.65rem] font-medium shadow-sm">
|
||||
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{locationText}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import Accordion from "./Accordion.svelte";
|
||||
import { t, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
let {
|
||||
embedUrl = null,
|
||||
linkUrl = null,
|
||||
locationText = null,
|
||||
label = "Karte",
|
||||
translations = null,
|
||||
}: {
|
||||
embedUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
locationText?: string | null;
|
||||
label?: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
let overlayVisible = $state(true);
|
||||
</script>
|
||||
|
||||
<Accordion id="map-section" {label} open class="mt-12">
|
||||
<div class="overflow-hidden rounded-lg border border-wald-200 shadow-sm">
|
||||
{#if embedUrl}
|
||||
<div>
|
||||
<div class="relative">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
width="600"
|
||||
height="300"
|
||||
class="block w-full border-0"
|
||||
loading="lazy"
|
||||
title={locationText ? `Karte: ${locationText}` : "Veranstaltungsort"}
|
||||
></iframe>
|
||||
{#if overlayVisible}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[1px] cursor-pointer select-none"
|
||||
aria-label="Karte aktivieren"
|
||||
onclick={() => { overlayVisible = false; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') overlayVisible = false; }}
|
||||
>
|
||||
<div class="flex items-center gap-2 rounded-full bg-white/90 px-4 py-2 text-xs font-medium text-zinc-700 shadow">
|
||||
<Icon icon="mdi:cursor-default-click" class="size-4 shrink-0" aria-hidden="true" />
|
||||
{t(translations, T.post_map_activate)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if linkUrl}
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-wald-50 text-wald-700 text-[.65rem] font-medium hover:bg-wald-100 transition-colors border-t border-wald-200"
|
||||
>
|
||||
<Icon icon="mdi:open-in-new" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t(translations, T.post_map_open_osm)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if linkUrl}
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-wald-50 text-wald-700 text-xs font-medium hover:bg-wald-100 transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
|
||||
{locationText ?? t(translations, T.post_map_open_osm)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import ContentRows from "./ContentRows.svelte";
|
||||
import type { RowContentLayout } from "$lib/block-types";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
interface FooterEntry extends RowContentLayout {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
const justifyClass = $derived(
|
||||
footer?.row1JustifyContent === "end"
|
||||
? "justify-end"
|
||||
: footer?.row1JustifyContent === "between"
|
||||
? "justify-between"
|
||||
: footer?.row1JustifyContent === "around"
|
||||
? "justify-around"
|
||||
: footer?.row1JustifyContent === "evenly"
|
||||
? "justify-evenly"
|
||||
: footer?.row1JustifyContent === "start"
|
||||
? "justify-start"
|
||||
: "justify-center",
|
||||
);
|
||||
|
||||
const hasRowContent = $derived(
|
||||
!!footer &&
|
||||
((footer.row1Content?.length ?? 0) > 0 ||
|
||||
(footer.row2Content?.length ?? 0) > 0 ||
|
||||
(footer.row3Content?.length ?? 0) > 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if footer}
|
||||
<footer
|
||||
class="mt-auto bg-zinc-950 text-white py-6"
|
||||
data-footer-id={footer.id}
|
||||
>
|
||||
<div class="container-custom py-6">
|
||||
{#if hasRowContent}
|
||||
<div class="content-footer text-xs">
|
||||
<ContentRows layout={footer} translations={translations} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-white/80 {justifyClass} flex">
|
||||
{t(T.footer_copyright) !== T.footer_copyright
|
||||
? t(T.footer_copyright)
|
||||
: `© ${new Date().getFullYear()} Windwiderstand`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import '$lib/iconify-offline';
|
||||
import { slide } from "svelte/transition";
|
||||
import Icon from "@iconify/svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import { overlayOpen, overlayClose } from "$lib/overlay-store";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
interface NavLink {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
href: string;
|
||||
icon: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
links = [],
|
||||
socialLinks = [],
|
||||
logoUrl = null,
|
||||
logoSvgHtml = null,
|
||||
translations = {},
|
||||
}: {
|
||||
links?: NavLink[];
|
||||
socialLinks?: SocialLink[];
|
||||
logoUrl?: string | null;
|
||||
logoSvgHtml?: string | null;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
let menuOpen = $state(false);
|
||||
let searchOpen = $state(false);
|
||||
|
||||
function toggleMenu() {
|
||||
if (menuOpen) {
|
||||
menuOpen = false;
|
||||
} else {
|
||||
searchOpen = false;
|
||||
menuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen = false;
|
||||
}
|
||||
|
||||
function closeOverlays() {
|
||||
menuOpen = false;
|
||||
searchOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (searchOpen) menuOpen = false;
|
||||
});
|
||||
$effect(() => {
|
||||
if (menuOpen) searchOpen = false;
|
||||
});
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const open = menuOpen || searchOpen;
|
||||
overlayOpen.set(open);
|
||||
overlayClose.set(open ? closeOverlays : null);
|
||||
});
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const open = menuOpen || searchOpen;
|
||||
if (open) window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
document.documentElement.style.overflow = open ? "hidden" : "";
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.documentElement.style.overflow = "";
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header
|
||||
id="horizontal-navigation"
|
||||
class="sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-linear-to-br from-stein-50 from-0% via-wald-100/90 via-42% to-himmel-100 to-100%"
|
||||
>
|
||||
<div class="flex items-center justify-between container-custom py-2">
|
||||
<a
|
||||
href="/"
|
||||
class="logo flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
|
||||
>
|
||||
{#if logoSvgHtml}
|
||||
<span class="logo-svg h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10" aria-hidden="true">{@html logoSvgHtml}</span>
|
||||
{:else if logoUrl && logoUrl.trim() !== ""}
|
||||
<img src={logoUrl} alt="" class="h-10 w-auto object-contain" />
|
||||
{:else}
|
||||
<span>{t(T.site_name_fallback)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Desktop: horizontale Nav (ab lg) -->
|
||||
<nav
|
||||
class="hidden lg:flex items-center gap-4"
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="hover:animate-pulse transition-all text-xs tracking-wide"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div><span class="opacity-20">|</span></div>
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={social.label || t(T.header_social_aria)}
|
||||
>
|
||||
<Icon icon={social.icon} aria-hidden="true" />
|
||||
</a>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
|
||||
<div class="lg:hidden flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
class="text-2xl text-error!"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="text-2xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
|
||||
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
|
||||
aria-controls="mobile-nav"
|
||||
onclick={toggleMenu}
|
||||
>
|
||||
<span class="sr-only"
|
||||
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</span
|
||||
>
|
||||
{#if menuOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Search bind:open={searchOpen} />
|
||||
|
||||
<!-- Mobile: Dropdown-Menü (unter lg) -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<nav
|
||||
class="container-custom py-4 flex flex-col gap-1"
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if socialLinks.length > 0}
|
||||
<div
|
||||
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
|
||||
>
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
|
||||
aria-label={social.label || t(T.header_social_aria)}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<Icon
|
||||
icon={social.icon}
|
||||
class="text-[1.5rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { overlayOpen, overlayClose } from "$lib/overlay-store";
|
||||
import { useTranslate } from "$lib/translations";
|
||||
import { T } from "$lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
function handleClick() {
|
||||
get(overlayClose)?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $overlayOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
|
||||
aria-label={t(T.header_overlay_close)}
|
||||
onclick={handleClick}
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import '$lib/iconify-offline';
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "$lib/translations";
|
||||
import { T } from "$lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
let {
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
basePath = "/posts",
|
||||
activeTag = null,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
basePath?: string;
|
||||
activeTag?: string | null;
|
||||
} = $props();
|
||||
|
||||
function tagHref(tagSlug: string | null): string {
|
||||
if (!tagSlug) return basePath;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
|
||||
}
|
||||
|
||||
function pageHref(page: number): string {
|
||||
if (page <= 1) return tagHref(activeTag ?? null);
|
||||
const tagPart = activeTag
|
||||
? `/tag/${encodeURIComponent(activeTag)}`
|
||||
: "";
|
||||
return `${basePath}${tagPart}/page/${page}`;
|
||||
}
|
||||
|
||||
const showPagination = $derived(totalPages > 1);
|
||||
const hasPrev = $derived(currentPage > 1);
|
||||
const hasNext = $derived(currentPage < totalPages);
|
||||
const prevPage = $derived(currentPage - 1);
|
||||
const nextPage = $derived(currentPage + 1);
|
||||
|
||||
const pageNumbers = $derived.by(() => {
|
||||
const max = 5;
|
||||
let start = Math.max(1, currentPage - Math.floor(max / 2));
|
||||
const end = Math.min(totalPages, start + max - 1);
|
||||
if (end - start + 1 < max) start = Math.max(1, end - max + 1);
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
});
|
||||
|
||||
const paginationLinkClass =
|
||||
" bg-linear-to-b from-stein-50 to-stein-100 rounded-full h-6 w-6 mx-[2px] flex justify-center items-center text-stein-950 no-underline text-xs font-medium border border-stein-200 shadow-md";
|
||||
</script>
|
||||
|
||||
{#if showPagination}
|
||||
<div class="inline-flex py-4">
|
||||
<div class="flex">
|
||||
{#if hasPrev}
|
||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
|
||||
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
{#each pageNumbers as num}
|
||||
<a
|
||||
href={pageHref(num)}
|
||||
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
|
||||
aria-current={num === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{num}
|
||||
</a>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
|
||||
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import PostMeta from "./PostMeta.svelte";
|
||||
import EventBadges from "./EventBadges.svelte";
|
||||
|
||||
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
|
||||
|
||||
let {
|
||||
post,
|
||||
href,
|
||||
tagBase = "/posts/tag",
|
||||
}: {
|
||||
post: PostWithImage;
|
||||
href: string;
|
||||
tagBase?: string;
|
||||
} = $props();
|
||||
|
||||
const resolvedImg = $derived(post._resolvedImageUrl);
|
||||
|
||||
type PostWithEvent = PostWithImage & {
|
||||
isEvent?: boolean;
|
||||
eventDate?: string;
|
||||
eventLocation?: { text?: string };
|
||||
};
|
||||
const p = post as PostWithEvent;
|
||||
|
||||
const eventDate = $derived(p.isEvent && p.eventDate ? p.eventDate : null);
|
||||
const locationText = $derived(p.eventLocation?.text ?? null);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={href}
|
||||
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}
|
||||
<img
|
||||
src={resolvedImg}
|
||||
alt={post.headline ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if eventDate}
|
||||
<div class="absolute bottom-2 left-2 right-2">
|
||||
<EventBadges {eventDate} {locationText} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-start p-4">
|
||||
<PostMeta
|
||||
date={post.created ?? null}
|
||||
tags={post.postTag ?? []}
|
||||
{tagBase}
|
||||
/>
|
||||
<div>
|
||||
<h5 class="line-clamp-2 font-medium leading-tight mb-1">
|
||||
{post.headline ?? post.linkName ?? post.slug ?? post._slug}
|
||||
</h5>
|
||||
{#if post.subheadline}
|
||||
<div class="text-xs line-clamp-2 mb-1">
|
||||
{post.subheadline}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.excerpt}
|
||||
<p class="text-xs font-light line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { formatPostDate } from "$lib/blog-utils";
|
||||
import Tag from "./Tag.svelte";
|
||||
import Tags from "./Tags.svelte";
|
||||
|
||||
type TagItem = string | { name?: string; _slug?: string };
|
||||
type TagVariant = "white" | "green" | "blue";
|
||||
|
||||
let {
|
||||
date = null,
|
||||
tags = [],
|
||||
tagVariant = "white",
|
||||
tagBase = null,
|
||||
}: {
|
||||
date?: string | null;
|
||||
tags?: TagItem[];
|
||||
tagVariant?: TagVariant;
|
||||
tagBase?: string | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-1 min-w-0 flex flex-nowrap gap-1 items-center overflow-x-auto overflow-y-hidden p-1 pb-4">
|
||||
{#if date}
|
||||
<Tag label={formatPostDate(date)} variant="date" />
|
||||
{/if}
|
||||
<div class="flex flex-nowrap gap-1 items-center shrink-0">
|
||||
<Tags tags={tags} variant={tagVariant} tagBase={tagBase} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…).
|
||||
*/
|
||||
interface Props {
|
||||
/** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
}
|
||||
|
||||
let { src, width, height, alt = "", class: className = "", loading = "lazy" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
class={className}
|
||||
{loading}
|
||||
/>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { tick } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
|
||||
type SearchHit = {
|
||||
type: "page" | "post";
|
||||
slug: string;
|
||||
href: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
excerpt: string;
|
||||
image: string | null;
|
||||
created: string | null;
|
||||
isEvent?: boolean;
|
||||
eventDate?: string | null;
|
||||
};
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
let query = $state("");
|
||||
let allHits = $state<SearchHit[] | null>(null);
|
||||
let loading = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
||||
const results = $derived.by<SearchHit[]>(() => {
|
||||
if (!allHits || normalizedQuery.length < 2) return [];
|
||||
const q = normalizedQuery;
|
||||
return allHits
|
||||
.filter((h) => {
|
||||
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 40);
|
||||
});
|
||||
|
||||
async function loadIndex() {
|
||||
if (allHits || loading) return;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const res = await fetch("/api/search-index");
|
||||
if (!res.ok) throw new Error("search index fetch failed");
|
||||
const data = (await res.json()) as { hits: SearchHit[] };
|
||||
allHits = data.hits ?? [];
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
allHits = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
void loadIndex();
|
||||
tick().then(() => inputEl?.focus());
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeSearch();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id="search-panel"
|
||||
class="border-t border-white/30 bg-white/95 backdrop-blur overflow-hidden"
|
||||
role="region"
|
||||
aria-label="Suche"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]">
|
||||
<label class="relative block max-w-xl mx-auto">
|
||||
<span class="sr-only">Suche</span>
|
||||
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-zinc-500">
|
||||
<Icon icon="mdi:magnify" class="h-5 w-5" />
|
||||
</span>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Seiten und Beiträge durchsuchen…"
|
||||
class="w-full rounded-sm border border-zinc-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (query = "")}
|
||||
aria-label="Eingabe leeren"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
|
||||
>
|
||||
<Icon icon="mdi:close" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="max-w-xl mx-auto mt-4 text-sm">
|
||||
{#if loading}
|
||||
<p class="text-zinc-500 text-center py-2">Lade Suchindex…</p>
|
||||
{:else if loadError}
|
||||
<p class="text-red-600 text-center py-2">Suche nicht verfügbar: {loadError}</p>
|
||||
{:else if normalizedQuery.length < 2}
|
||||
<p class="text-zinc-500 text-center py-2">Mindestens 2 Zeichen eingeben.</p>
|
||||
{:else if results.length === 0}
|
||||
<p class="text-zinc-500 text-center py-2">Keine Treffer.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-zinc-200">
|
||||
{#each results as hit}
|
||||
<li>
|
||||
<a
|
||||
href={hit.href}
|
||||
onclick={closeSearch}
|
||||
class="flex items-start gap-3 py-3 hover:bg-zinc-50 rounded-sm px-2 -mx-2 no-underline"
|
||||
>
|
||||
{#if hit.image}
|
||||
<img
|
||||
src={hit.image}
|
||||
alt=""
|
||||
class="w-20 h-12 object-cover shrink-0 rounded-sm border border-zinc-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-20 h-12 shrink-0 bg-zinc-100 rounded-sm border border-zinc-200 flex items-center justify-center text-zinc-400">
|
||||
<Icon icon={hit.type === "post" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-zinc-500">
|
||||
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
|
||||
{#if hit.isEvent && hit.eventDate}
|
||||
<span class="text-wald-700">{formatDate(hit.eventDate)}</span>
|
||||
{:else if hit.created}
|
||||
<span>{formatDate(hit.created)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="font-medium text-zinc-900 truncate">{hit.title}</div>
|
||||
{#if hit.excerpt}
|
||||
<div class="text-xs text-zinc-600 line-clamp-2">{hit.excerpt}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import "$lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
type Variant = "white" | "green" | "blue" | "inactive" | "date";
|
||||
|
||||
let {
|
||||
label,
|
||||
variant = "white",
|
||||
href = null,
|
||||
active = false,
|
||||
onclick = null,
|
||||
icon = null,
|
||||
customColor = null,
|
||||
}: {
|
||||
label: string;
|
||||
variant?: Variant;
|
||||
href?: string | null;
|
||||
active?: boolean;
|
||||
onclick?: (() => void) | null;
|
||||
icon?: string | null;
|
||||
customColor?: string | null;
|
||||
} = $props();
|
||||
|
||||
function isLightBackground(cssColor: string): boolean {
|
||||
const s = cssColor.trim();
|
||||
const hex6 = /^#([0-9a-f]{6})$/i.exec(s);
|
||||
const hex3 = /^#([0-9a-f]{3})$/i.exec(s);
|
||||
let r = 200;
|
||||
let g = 200;
|
||||
let b = 200;
|
||||
if (hex6) {
|
||||
const n = parseInt(hex6[1], 16);
|
||||
r = (n >> 16) & 255;
|
||||
g = (n >> 8) & 255;
|
||||
b = n & 255;
|
||||
} else if (hex3) {
|
||||
const h = hex3[1];
|
||||
r = parseInt(h[0] + h[0], 16);
|
||||
g = parseInt(h[1] + h[1], 16);
|
||||
b = parseInt(h[2] + h[2], 16);
|
||||
} else if (s.startsWith("rgb")) {
|
||||
const m = s.match(/\d+/g);
|
||||
if (m && m.length >= 3) {
|
||||
r = Number(m[0]);
|
||||
g = Number(m[1]);
|
||||
b = Number(m[2]);
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return lum > 0.55;
|
||||
}
|
||||
|
||||
const baseClass =
|
||||
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0";
|
||||
const variantClass = $derived.by(() => {
|
||||
if (customColor?.trim()) {
|
||||
return "";
|
||||
}
|
||||
if (variant === "white")
|
||||
return "bg-stein-100 text-stein-800";
|
||||
if (variant === "green") return "bg-wald-100 text-wald-800";
|
||||
if (variant === "blue") return "bg-sky-100/90 text-sky-900";
|
||||
if (variant === "inactive") return "bg-stein-200 text-stein-500";
|
||||
if (variant === "date") return "bg-wald-50 text-wald-800";
|
||||
return "bg-stein-200 text-stein-500";
|
||||
});
|
||||
const pillStyle = $derived.by(() => {
|
||||
const c = customColor?.trim();
|
||||
if (!c) return "";
|
||||
const light = isLightBackground(c);
|
||||
const fg = light ? "#111827" : "#fafafa";
|
||||
return `background-color: ${c}; color: ${fg};`;
|
||||
});
|
||||
const interactiveClass = $derived.by(() =>
|
||||
href || onclick ? "hover:brightness-[0.97] active:brightness-[0.94]" : "",
|
||||
);
|
||||
const classes = $derived.by(
|
||||
() =>
|
||||
`${baseClass} ${variantClass} ${interactiveClass} ${active ? "pointer-events-none opacity-80" : ""}`.trim(),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
href={href}
|
||||
class={classes}
|
||||
style={pillStyle || undefined}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
{#if icon}
|
||||
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{/if}
|
||||
{label}
|
||||
</a>
|
||||
{:else if onclick}
|
||||
<button
|
||||
type="button"
|
||||
class={classes}
|
||||
style={pillStyle || undefined}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onclick={onclick}
|
||||
>
|
||||
{#if icon}
|
||||
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{/if}
|
||||
{label}
|
||||
</button>
|
||||
{:else}
|
||||
<span class={classes} style={pillStyle || undefined}>
|
||||
{#if icon}
|
||||
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
|
||||
{/if}
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import Tag from "./Tag.svelte";
|
||||
|
||||
type TagItem =
|
||||
| string
|
||||
| { name?: string; _slug?: string; icon?: string; color?: string };
|
||||
type Variant = "white" | "green" | "blue";
|
||||
|
||||
let {
|
||||
tags = [],
|
||||
variant = "white",
|
||||
tagBase = null,
|
||||
}: {
|
||||
tags?: TagItem[];
|
||||
variant?: Variant;
|
||||
tagBase?: string | null;
|
||||
} = $props();
|
||||
|
||||
function displayName(t: TagItem): string {
|
||||
if (typeof t === "string") return t;
|
||||
return (
|
||||
(t as { name?: string; _slug?: string })?.name ??
|
||||
(t as { _slug?: string })?._slug ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function slug(t: TagItem): string {
|
||||
if (typeof t === "string") return t;
|
||||
return (
|
||||
(t as { _slug?: string })?._slug ?? (t as { name?: string })?.name ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function tagIcon(t: TagItem): string | null {
|
||||
if (typeof t === "string") return null;
|
||||
const i = (t as { icon?: string }).icon?.trim();
|
||||
return i || null;
|
||||
}
|
||||
|
||||
function tagColor(t: TagItem): string | null {
|
||||
if (typeof t === "string") return null;
|
||||
const c = (t as { color?: string }).color?.trim();
|
||||
return c || null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each (tags ?? []) as t}
|
||||
{@const name = displayName(t)}
|
||||
{#if name}
|
||||
<Tag
|
||||
label={name}
|
||||
variant={variant}
|
||||
href={tagBase ? `${tagBase}/${slug(t)}` : null}
|
||||
icon={tagIcon(t)}
|
||||
customColor={tagColor(t)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
|
||||
* und Titel/Subtitle der aktuellen Page (Markdown).
|
||||
*/
|
||||
import { marked } from "marked";
|
||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
interface Props {
|
||||
banner: FullwidthBannerEntry | null;
|
||||
resolvedImages?: string[];
|
||||
/** Headline der Page (Markdown). */
|
||||
headline?: string;
|
||||
/** Subheadline der Page (Markdown). */
|
||||
subheadline?: string;
|
||||
}
|
||||
|
||||
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
|
||||
|
||||
const headlineHtml = $derived(
|
||||
headline?.trim() ? (marked.parseInline(headline) as string) : "",
|
||||
);
|
||||
const subheadlineHtml = $derived(
|
||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
||||
);
|
||||
|
||||
const hasImage = $derived(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);
|
||||
</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"
|
||||
/>
|
||||
{#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}
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { Translations, TranslationReplacements } from "$lib/translations";
|
||||
import { T_CONTEXT_KEY, replacePlaceholders } from "$lib/translations";
|
||||
|
||||
let {
|
||||
translations = {},
|
||||
children,
|
||||
}: {
|
||||
translations?: Translations;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const t = (key: string, replacements?: TranslationReplacements): string => {
|
||||
const raw = translations?.[key] ?? key;
|
||||
return replacePlaceholders(raw, replacements);
|
||||
};
|
||||
|
||||
setContext(T_CONTEXT_KEY, t);
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type {
|
||||
CalendarBlockData,
|
||||
CalendarItemData,
|
||||
} from "$lib/block-types";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
import "$lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
type EventCardItem = CalendarItemData & {
|
||||
date: Date | null;
|
||||
timeStr: string;
|
||||
};
|
||||
|
||||
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const raw = block.items ?? [];
|
||||
return raw
|
||||
.filter(
|
||||
(x): x is CalendarItemData =>
|
||||
typeof x === "object" &&
|
||||
x !== null &&
|
||||
"terminZeit" in x &&
|
||||
"title" in x,
|
||||
)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
date: parseDate(x.terminZeit),
|
||||
timeStr: formatTime(x.terminZeit),
|
||||
}))
|
||||
.filter((x) => x.date)
|
||||
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
|
||||
});
|
||||
|
||||
function parseDate(iso: string): Date | null {
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
|
||||
function getEventLinkHref(link: unknown): string {
|
||||
if (typeof link === "string" && link) return link;
|
||||
if (link && typeof link === "object") {
|
||||
const o = link as { url?: string; _slug?: string };
|
||||
if (typeof o.url === "string" && o.url) return o.url;
|
||||
if (typeof o._slug === "string" && o._slug)
|
||||
return `/post/${encodeURIComponent(o._slug)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
if (h === 0 && m === 0) return "";
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const eventsByDay = $derived.by(() => {
|
||||
const map = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
if (!item.date) continue;
|
||||
const key = dayKey(item.date);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(item);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let currentMonth = $state(new Date());
|
||||
let selectedDay = $state<string | null>(null);
|
||||
|
||||
const year = $derived(currentMonth.getFullYear());
|
||||
const month = $derived(currentMonth.getMonth());
|
||||
const monthLabel = $derived(
|
||||
currentMonth.toLocaleDateString("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const daysInMonth = $derived.by(() => {
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const count = last.getDate();
|
||||
const startWeekday = (first.getDay() + 6) % 7;
|
||||
return { count, startWeekday, last };
|
||||
});
|
||||
|
||||
const calendarDays = $derived.by(() => {
|
||||
const { count, startWeekday } = daysInMonth;
|
||||
const empty = Array(startWeekday).fill(null);
|
||||
const days = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => new Date(year, month, i + 1),
|
||||
);
|
||||
return [...empty, ...days];
|
||||
});
|
||||
|
||||
const selectedDayEvents = $derived(
|
||||
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
|
||||
);
|
||||
|
||||
/** Nächste 3 Termine ab heute – zur Laufzeit gefiltert. */
|
||||
const nextUpcomingEvents = $derived.by(() => {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const todayMs = startOfToday.getTime();
|
||||
return items
|
||||
.filter((item) => item.date && item.date.getTime() >= todayMs)
|
||||
.slice(0, 3);
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth = new Date(year, month - 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth = new Date(year, month + 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function selectDay(key: string) {
|
||||
selectedDay = selectedDay === key ? null : key;
|
||||
}
|
||||
|
||||
const todayKey = $derived(dayKey(new Date()));
|
||||
|
||||
const weekdays = $derived([
|
||||
t(T.calendar_weekday_mo),
|
||||
t(T.calendar_weekday_di),
|
||||
t(T.calendar_weekday_mi),
|
||||
t(T.calendar_weekday_do),
|
||||
t(T.calendar_weekday_fr),
|
||||
t(T.calendar_weekday_sa),
|
||||
t(T.calendar_weekday_so),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
||||
data-block-type="calendar"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#snippet eventCard(item: EventCardItem)}
|
||||
{@const eventHref = getEventLinkHref(item.link)}
|
||||
<li class="bg-himmel-100 p-2">
|
||||
<div class="flex gap-1">
|
||||
{#if item.date}
|
||||
<div class="text-xs text-stein-500 mb-0.5">
|
||||
{item.date.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
{#if item.timeStr}
|
||||
<span class="text-xs text-stein-600">/</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.timeStr}
|
||||
<div class="text-xs text-stein-600">
|
||||
{item.timeStr}
|
||||
{t(T.calendar_time_suffix)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
{:else}
|
||||
<div class="text-base font-medium text-stein-900">{item.title}</div>
|
||||
{/if}
|
||||
|
||||
{#if item.description}
|
||||
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
|
||||
{item.description}
|
||||
</p>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{:else if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
{#if block.title}
|
||||
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
|
||||
{block.title}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
|
||||
<!-- Links: Kalender -->
|
||||
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||||
aria-label={t(T.calendar_prev_month)}
|
||||
onclick={prevMonth}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
||||
>{monthLabel}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||||
aria-label={t(T.calendar_next_month)}
|
||||
onclick={nextMonth}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
|
||||
{#each weekdays as w}
|
||||
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
|
||||
{/each}
|
||||
{#each calendarDays as d}
|
||||
{#if d === null}
|
||||
<div class="aspect-square" aria-hidden="true"></div>
|
||||
{:else}
|
||||
{@const key = dayKey(d)}
|
||||
{@const isCurrentMonth = d.getMonth() === month}
|
||||
{@const events = eventsByDay.get(key) ?? []}
|
||||
{@const hasEvents = events.length > 0}
|
||||
{@const isSelected = selectedDay === key}
|
||||
{@const isFuture = key >= todayKey}
|
||||
{#if hasEvents}
|
||||
<button
|
||||
type="button"
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
|
||||
? 'text-himmel-100 hover:bg-himmel-700'
|
||||
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
|
||||
? 'ring-2 ring-himmel-400 bg-himmel-600'
|
||||
: ''}"
|
||||
onclick={() => selectDay(key)}
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{events.length}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
||||
? 'text-himmel-100'
|
||||
: 'text-himmel-100 opacity-50'}"
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechts: Events -->
|
||||
<div class="calendar-events-wrapper min-h-0">
|
||||
<div
|
||||
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{#if items.length === 0}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
|
||||
{:else if selectedDay}
|
||||
<p class="text-xs font-medium text-stein-500 mb-2">
|
||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
|
||||
{#each selectedDayEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if nextUpcomingEvents.length > 0}
|
||||
<p class="text-xs uppercase font-medium text-stein-500">
|
||||
{t(T.calendar_next_events)}
|
||||
</p>
|
||||
<ul
|
||||
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
|
||||
>
|
||||
{#each nextUpcomingEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { HeadlineBlockData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: HeadlineBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const alignClass = $derived(
|
||||
block.align === "center"
|
||||
? "text-center"
|
||||
: block.align === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="headline" data-block-slug={block._slug}>
|
||||
<svelte:element
|
||||
this={Tag}
|
||||
class={alignClass}
|
||||
>
|
||||
{block.text ?? ""}
|
||||
</svelte:element>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { HtmlBlockData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: HtmlBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} content-html"
|
||||
data-block-type="html"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{@html block.html ?? ""}
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import '$lib/iconify-offline';
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { IframeBlockData } from "$lib/block-types";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen. */
|
||||
let overlayActive = $state(true);
|
||||
|
||||
/** Nimmt entweder eine reine URL oder Iframe-HTML und liefert die src-URL. */
|
||||
function getIframeSrc(raw: string | undefined): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) return "";
|
||||
const s = raw.trim();
|
||||
if (s.startsWith("http") || s.startsWith("//")) return s;
|
||||
const match = s.match(/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const src = $derived(getIframeSrc(block.iframe));
|
||||
|
||||
function activateMap() {
|
||||
overlayActive = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="iframe" data-block-slug={block._slug}>
|
||||
{#if src}
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
|
||||
<iframe
|
||||
title={block.content ?? "Embed"}
|
||||
src={src}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{#if overlayActive}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
|
||||
aria-label={t(T.iframe_activate_aria)}
|
||||
onclick={activateMap}
|
||||
onkeydown={(e) => e.key === "Enter" && activateMap()}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:cursor-default-click"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
class="block text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">{t(T.iframe_missing)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { ImageBlockData } from "$lib/block-types";
|
||||
import CmsImage from "$lib/components/CmsImage.svelte";
|
||||
|
||||
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));
|
||||
$effect(() => {
|
||||
if (!rawSrc && block._slug) {
|
||||
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
|
||||
hasImage: !!block.image,
|
||||
hasImg: !!block.img,
|
||||
imageType: typeof block.image,
|
||||
imgType: typeof block.img,
|
||||
imgSrc:
|
||||
block.img && typeof block.img === "object"
|
||||
? (block.img as { src?: string }).src
|
||||
: undefined,
|
||||
imageSrc:
|
||||
block.image && typeof block.image === "object"
|
||||
? (block.image as { src?: string }).src
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
const title = $derived(
|
||||
typeof imageSource === "object" && imageSource
|
||||
? "description" in imageSource && imageSource.description
|
||||
? imageSource.description
|
||||
: "title" in imageSource
|
||||
? (imageSource as { title?: string }).title
|
||||
: undefined
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
||||
{#if rawSrc}
|
||||
<figure class="max-w-[400px]">
|
||||
<CmsImage
|
||||
src={rawSrc}
|
||||
width={800}
|
||||
format="webp"
|
||||
quality={85}
|
||||
fit="contain"
|
||||
alt={block.caption ?? title ?? ""}
|
||||
class="w-full rounded-sm"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if block.caption}
|
||||
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Bild nicht verfügbar.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
|
||||
import "$lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "$lib/translations";
|
||||
import { T } from "$lib/translations";
|
||||
import CmsImage from "$lib/components/CmsImage.svelte";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
? (marked.parse(block.description) as string)
|
||||
: "",
|
||||
);
|
||||
const images = $derived(
|
||||
(block.images ?? []).filter((i): i is ImageGalleryImage => i != null && typeof i === "object"),
|
||||
);
|
||||
const withUrl = $derived(
|
||||
images
|
||||
.map((img) => {
|
||||
const url = imageUrl(img);
|
||||
return url != null ? { img, url } : null;
|
||||
})
|
||||
.filter((x): x is { img: ImageGalleryImage; url: string } => 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;
|
||||
}
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % withUrl.length;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
let startX = $state(0);
|
||||
let isDragging = $state(false);
|
||||
let dragDelta = $state(0);
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (withUrl.length <= 1) return;
|
||||
startX = e.clientX;
|
||||
isDragging = true;
|
||||
dragDelta = 0;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
dragDelta = e.clientX - startX;
|
||||
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
}
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if (withUrl.length <= 1) return;
|
||||
startX = e.touches[0].clientX;
|
||||
isDragging = true;
|
||||
dragDelta = 0;
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging || !e.touches[0]) return;
|
||||
const delta = e.touches[0].clientX - startX;
|
||||
dragDelta = delta;
|
||||
if (Math.abs(delta) > 10) e.preventDefault();
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown max-w-none prose prose-zinc mb-4">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if withUrl.length === 0}
|
||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<CmsImage
|
||||
src={withUrl[0].url}
|
||||
width={1280}
|
||||
height={720}
|
||||
fit="cover"
|
||||
format="webp"
|
||||
quality={80}
|
||||
alt={withUrl[0].img.description ?? ""}
|
||||
class="w-full rounded-sm object-cover aspect-video"
|
||||
loading="eager"
|
||||
/>
|
||||
{#if withUrl[0].img.description}
|
||||
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
|
||||
role="region"
|
||||
aria-label={t(T.image_gallery_swipe_aria)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchmove={onTouchMove}
|
||||
ontouchend={onTouchEnd}
|
||||
ontouchcancel={onTouchEnd}
|
||||
>
|
||||
{#key currentIndex}
|
||||
{@const current = withUrl[currentIndex]}
|
||||
<figure
|
||||
class="m-0 absolute inset-0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<CmsImage
|
||||
src={current.url}
|
||||
width={1280}
|
||||
height={720}
|
||||
fit="cover"
|
||||
format="webp"
|
||||
quality={80}
|
||||
alt={current.img.description ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading={currentIndex === 0 ? "eager" : "lazy"}
|
||||
/>
|
||||
{#if current.img.description}
|
||||
<figcaption
|
||||
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
|
||||
>
|
||||
{current.img.description}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label={t(T.image_gallery_prev_aria)}
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label={t(T.image_gallery_next_aria)}
|
||||
onclick={next}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? 'bg-wald-600'
|
||||
: 'bg-stein-300 hover:bg-stein-400'}"
|
||||
onclick={() => (currentIndex = i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { LinkListBlockData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: LinkListBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(
|
||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||
);
|
||||
const links = $derived(block.links ?? []);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="link_list" data-block-slug={block._slug}>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2 font-semibold text-zinc-900">{block.headline}</h3>
|
||||
{/if}
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{#each links as link}
|
||||
{#if typeof link === "object" && link !== null && "url" in link}
|
||||
<li>
|
||||
<a
|
||||
href={(link as { url?: string }).url ?? "#"}
|
||||
class="underline text-green-700 hover:text-green-800"
|
||||
target={(link as { newTab?: boolean }).newTab ? "_blank" : undefined}
|
||||
rel={(link as { newTab?: boolean }).newTab ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{(link as { linkName?: string }).linkName ?? (link as { url?: string }).url}
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<span class="text-zinc-500">{typeof link === "string" ? link : "Link"}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { MarkdownBlockData } from "$lib/block-types";
|
||||
|
||||
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.resolvedContent ??
|
||||
(block.content && typeof block.content === "string"
|
||||
? (marked.parse(block.content) as string)
|
||||
: ""),
|
||||
);
|
||||
|
||||
const alignClass = $derived(
|
||||
block.alignment === "center"
|
||||
? "text-center"
|
||||
: block.alignment === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={layoutClasses}
|
||||
data-block-type="markdown"
|
||||
data-markdown-id={block.name}
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="markdown max-w-none {alignClass} {className}">
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { OpnFormBlockData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: OpnFormBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const formId = $derived((block.formId ?? "").trim());
|
||||
const baseUrl = $derived(
|
||||
(typeof block.baseUrl === "string" && block.baseUrl.trim()
|
||||
? block.baseUrl.trim()
|
||||
: "https://opnform.pm86.de"
|
||||
).replace(/\/$/, ""),
|
||||
);
|
||||
const iframeSrc = $derived(formId ? `${baseUrl}/forms/${formId}` : "");
|
||||
const iframeTitle = $derived(block.iframeTitle?.trim() || "Kontaktformular");
|
||||
|
||||
let iframeEl = $state<HTMLIFrameElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
if (!formId || !iframeEl) return;
|
||||
|
||||
const el = iframeEl;
|
||||
el.style.height = "600px";
|
||||
el.style.transition = "height 0.25s ease";
|
||||
el.setAttribute("scrolling", "no");
|
||||
|
||||
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.source !== el.contentWindow) return;
|
||||
const d = event.data;
|
||||
let h: number | undefined;
|
||||
if (typeof d === "number") {
|
||||
h = d;
|
||||
} else if (d && typeof d === "object") {
|
||||
h = d.height ?? d.frameHeight ?? d.iFrameHeight;
|
||||
} else if (typeof d === "string" && d.startsWith("[iFrameSizer]")) {
|
||||
const parts = d.split(":");
|
||||
h = parts.length >= 2 ? parseFloat(parts[1]) : undefined;
|
||||
}
|
||||
if (typeof h === "number" && h > 50) {
|
||||
el.style.height = `${Math.ceil(h)}px`;
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
function runInit() {
|
||||
const w = window as Window & {
|
||||
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
|
||||
};
|
||||
w.initEmbed?.(formId, { autoResize: true });
|
||||
}
|
||||
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
`script[data-opnform-embed-js="${baseUrl}"]`,
|
||||
);
|
||||
const w = window as Window & { initEmbed?: unknown };
|
||||
|
||||
if (existing) {
|
||||
if (typeof w.initEmbed === "function") {
|
||||
queueMicrotask(runInit);
|
||||
} else {
|
||||
existing.addEventListener("load", runInit, { once: true });
|
||||
}
|
||||
} else {
|
||||
const s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.async = true;
|
||||
s.src = scriptUrl;
|
||||
s.dataset.opnformEmbedJs = baseUrl;
|
||||
s.onload = () => runInit();
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4"
|
||||
data-block-type="opnform"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if iframeSrc}
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
style="border: none; width: 100%;"
|
||||
id={formId}
|
||||
title={iframeTitle}
|
||||
src={iframeSrc}
|
||||
></iframe>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
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 Card from "$lib/components/Card.svelte";
|
||||
import Badge from "$lib/components/Badge.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 organisationLogoAlt(o: OrganisationEntry): string {
|
||||
if (o.logo && typeof o.logo === "object" && o.logo.description) {
|
||||
return o.logo.description;
|
||||
}
|
||||
return o.name ?? "";
|
||||
}
|
||||
|
||||
let { block }: { block: OrganisationsBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(
|
||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||
);
|
||||
const compact = $derived(block.presentation === "compact");
|
||||
const orgs = $derived(block.organisations ?? []);
|
||||
const ctaHtml = $derived(
|
||||
block.addOrganisationMarkdown
|
||||
? (marked.parse(block.addOrganisationMarkdown) as string)
|
||||
: ""
|
||||
);
|
||||
|
||||
const addOrganisationCtaLink = $derived.by(() => {
|
||||
const raw = block.addOrganisationLink;
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||
if (!url) return undefined;
|
||||
const linkName =
|
||||
typeof raw.linkName === "string" && raw.linkName.trim() !== ""
|
||||
? raw.linkName.trim()
|
||||
: url;
|
||||
return { url, linkName, newTab: Boolean(raw.newTab) };
|
||||
});
|
||||
|
||||
const showAddOrganisationCard = $derived(
|
||||
Boolean(
|
||||
block.addOrganisationTitle?.trim() ||
|
||||
block.addOrganisationMarkdown?.trim() ||
|
||||
addOrganisationCtaLink
|
||||
)
|
||||
);
|
||||
|
||||
const addOrganisationCardClass = $derived(
|
||||
`${compact ? "border-dashed border-wald-300 bg-wald-50! p-3! gap-1.5!" : "border-dashed border-wald-300 bg-wald-50!"}${addOrganisationCtaLink ? " cursor-pointer" : ""}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
|
||||
{#if block.headline}
|
||||
<h3 class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}>{block.headline}</h3>
|
||||
{/if}
|
||||
<div
|
||||
class={compact
|
||||
? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3"
|
||||
: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}
|
||||
>
|
||||
{#each orgs as org, orgIndex}
|
||||
{#if typeof org === "object" && org !== null}
|
||||
{@const o = org as OrganisationEntry}
|
||||
{@const linkUrl =
|
||||
typeof o.link === "object" &&
|
||||
o.link !== null &&
|
||||
typeof o.link.url === "string" &&
|
||||
o.link.url.trim() !== ""
|
||||
? o.link.url.trim()
|
||||
: undefined}
|
||||
{@const logoSrc = organisationLogoSrc(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}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
||||
<svg
|
||||
class="h-7 w-7 text-stein-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id={fishMaskId}>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M24 30v14"
|
||||
/>
|
||||
<path
|
||||
fill="#555555"
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
|
||||
/>
|
||||
<circle cx="24" cy="24" r="2" fill="#fff" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
|
||||
{#if o.description}
|
||||
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card href={linkUrl}>
|
||||
<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}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-1"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
||||
<svg
|
||||
class="h-10 w-10 text-stein-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id={fishMaskId}>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M24 30v14"
|
||||
/>
|
||||
<path
|
||||
fill="#555555"
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
|
||||
/>
|
||||
<circle cx="24" cy="24" r="2" fill="#fff" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if o.badge && typeof o.badge === "object" && o.badge.label}
|
||||
<div class="absolute top-3 right-3">
|
||||
<Badge color={o.badge.color}>{o.badge.label}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
<h5 class="min-w-0 text-sm font-semibold leading-snug text-stein-900 truncate">{o.name ?? ""}</h5>
|
||||
{#if o.description}
|
||||
<p class="text-sm text-stein-600">{o.description}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if showAddOrganisationCard}
|
||||
<Card
|
||||
href={addOrganisationCtaLink?.url}
|
||||
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
|
||||
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
|
||||
class={addOrganisationCardClass}
|
||||
>
|
||||
{#if block.addOrganisationTitle}
|
||||
<h5
|
||||
class={compact
|
||||
? "text-sm font-semibold text-wald-800 leading-snug"
|
||||
: "text-base font-semibold text-wald-800 leading-snug"}
|
||||
>
|
||||
{block.addOrganisationTitle}
|
||||
</h5>
|
||||
{/if}
|
||||
{#if ctaHtml}
|
||||
<div class={compact ? "markdown text-xs text-wald-700" : "markdown text-sm text-wald-700"}>
|
||||
{@html ctaHtml}
|
||||
</div>
|
||||
{/if}
|
||||
{#if addOrganisationCtaLink}
|
||||
<p
|
||||
class={compact
|
||||
? "mb-0 mt-1 text-xs font-medium text-wald-800"
|
||||
: "mb-0 mt-2 text-sm font-medium text-wald-800"}
|
||||
>
|
||||
{addOrganisationCtaLink.linkName}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import PostCard from "../PostCard.svelte";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { PostOverviewBlockData } from "$lib/block-types";
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import { postHref } from "$lib/blog-utils";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
|
||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||
const t = useTranslate();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const textHtml = $derived(
|
||||
block.text && typeof block.text === "string"
|
||||
? (marked.parse(block.text) as string)
|
||||
: "",
|
||||
);
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]);
|
||||
const design = $derived(block.design ?? "cards");
|
||||
const tagBase = "/posts/tag";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="post-overview mb-4 {layoutClasses} {className}"
|
||||
data-block-type="post_overview"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2">{block.headline}</h3>
|
||||
{/if}
|
||||
{#if textHtml}
|
||||
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
|
||||
{/if}
|
||||
|
||||
{#if design === "list" && posts.length > 0}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if design === "cards" && posts.length > 0}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if posts.length > 0}
|
||||
<div class="mt-3 text-xs px-2">
|
||||
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline !no-underline">
|
||||
{t(T.post_overview_all)}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { QuoteBlockData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: QuoteBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const variantClass = $derived(block.variant === "right" ? "text-right" : "text-left");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
|
||||
<blockquote class="border-l-4 border-green-700 pl-4 {variantClass}">
|
||||
<p class="text-lg italic text-zinc-700">"{block.quote ?? ""}"</p>
|
||||
{#if block.author}
|
||||
<cite class="mt-1 block not-italic text-sm text-zinc-500">— {block.author}</cite>
|
||||
{/if}
|
||||
</blockquote>
|
||||
</div>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type {
|
||||
SearchableTextBlockData,
|
||||
SearchableTextFragment,
|
||||
} from "$lib/block-types";
|
||||
import "$lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import Tag from "../Tag.svelte";
|
||||
import Tags from "../Tags.svelte";
|
||||
import Tooltip from "$lib/ui/Tooltip.svelte";
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
|
||||
let {
|
||||
block,
|
||||
class: className = "",
|
||||
translations = {},
|
||||
}: {
|
||||
block: SearchableTextBlockData;
|
||||
class?: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const fragments = $derived.by(() => {
|
||||
const raw = block.textFragments ?? [];
|
||||
return raw
|
||||
.filter(
|
||||
(f): f is SearchableTextFragment =>
|
||||
typeof f === "object" && f !== null && ("title" in f || "text" in f),
|
||||
)
|
||||
.map((f) => {
|
||||
const tagsRaw = f.tags ?? [];
|
||||
const tags = tagsRaw
|
||||
.map((t) =>
|
||||
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
title: (f.title ?? "").trim(),
|
||||
text: (f.text ?? "").trim(),
|
||||
tags,
|
||||
textHtml: (f.text ?? "").trim()
|
||||
? (marked.parse((f.text ?? "").trim()) as string)
|
||||
: "",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const allTags = $derived(
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let selectedTag = $state("all");
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
|
||||
async function copyFragmentContent(
|
||||
fragment: { title: string; text: string },
|
||||
index: number,
|
||||
) {
|
||||
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(plain);
|
||||
copiedIndex = index;
|
||||
setTimeout(() => (copiedIndex = null), 2000);
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = plain;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
copiedIndex = index;
|
||||
setTimeout(() => (copiedIndex = null), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleFragments() {
|
||||
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const tag = selectedTag;
|
||||
return fragments.filter((f) => {
|
||||
const tagMatch = tag === "all" || f.tags.includes(tag);
|
||||
if (!tagMatch) return false;
|
||||
if (q === "") return true;
|
||||
const titleNorm = (f.title ?? "").toLowerCase().replace(/\s+/g, " ");
|
||||
const textNorm = (f.text ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
return titleNorm.includes(q) || textNorm.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
const visibleFragments = $derived(getVisibleFragments());
|
||||
|
||||
const HIGHLIGHT_CLASS = "bg-wald-200 text-wald-900 rounded px-0.5";
|
||||
|
||||
function highlightInText(text: string, term: string): string {
|
||||
if (!term.trim()) return escapeHtml(text);
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
return escapeHtml(text).replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
}
|
||||
|
||||
function highlightInHtml(html: string, term: string): string {
|
||||
if (!term.trim()) return html;
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
const parts = html.split(/(<[^>]+>)/g);
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith("<")) return part;
|
||||
return part.replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
? (marked.parse(block.description) as string)
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
|
||||
data-block-type="searchable_text"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="p-4 md:p-6">
|
||||
{#if block.title}
|
||||
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">
|
||||
{block.title}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<header
|
||||
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Tooltip content={helpTooltipText} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
|
||||
aria-label={t(T.searchable_text_help_aria)}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:help-circle-outline"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={t(T.searchable_text_placeholder)}
|
||||
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label={t(T.searchable_text_search_aria)}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label={t(T.searchable_text_clear_search)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if allTags.length > 0}
|
||||
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
|
||||
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
|
||||
<Tag
|
||||
label={t(T.searchable_text_tag_all)}
|
||||
variant={selectedTag === "all" ? "green" : "white"}
|
||||
active={selectedTag === "all"}
|
||||
onclick={() => (selectedTag = "all")}
|
||||
/>
|
||||
{#each allTags as tag}
|
||||
<Tag
|
||||
label={tag}
|
||||
variant={selectedTag === tag ? "green" : "white"}
|
||||
active={selectedTag === tag}
|
||||
onclick={() => (selectedTag = tag)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>{t(T.searchable_text_no_results)}</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
||||
: t(T.searchable_text_count_filtered, {
|
||||
visible: visibleFragments.length,
|
||||
total: fragments.length,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0 bg-stein-100">
|
||||
{#each visibleFragments as fragment, i}
|
||||
<details class="group border-b border-stein-200 last:border-b-0">
|
||||
<summary
|
||||
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="grow min-w-0">
|
||||
{@html highlightInText(
|
||||
fragment.title || t(T.searchable_text_untitled),
|
||||
searchQuery.trim(),
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<Tags tags={fragment.tags} variant="white" />
|
||||
</span>
|
||||
{/if}
|
||||
</summary>
|
||||
<div
|
||||
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
|
||||
>
|
||||
<div class="flex justify-end mb-3 -mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon
|
||||
icon="mdi:check"
|
||||
class="size-4 text-wald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copied)}</span>
|
||||
{:else}
|
||||
<Icon
|
||||
icon="mdi:content-copy"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copy_button)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { YoutubeVideoBlockData } from "$lib/block-types";
|
||||
import { useTranslate } from "$lib/translations";
|
||||
import { T } from "$lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const embedUrl = $derived(
|
||||
block.youtubeId
|
||||
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
|
||||
{#if block.youtubeId}
|
||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
|
||||
<iframe
|
||||
title={block.title ?? t(T.youtube_title_fallback)}
|
||||
src={embedUrl}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
{#if block.description}
|
||||
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user