feat: event badges, map, accordion, icon components
Deploy to Firebase Hosting / deploy (push) Failing after 1m26s

- EventBadges + EventMap for post event date/location display
- Accordion + Ico utility components
- PostCard shows event badge overlay on image when post is event
- post/[slug] renders event details with map
- markdown hr styling (dotted)
- disable Astro devToolbar
- regenerated cms-api types (includes navgroup, event fields)
This commit is contained in:
Peter Meier
2026-04-14 10:54:32 +02:00
parent bc114dfe92
commit 0814533520
9 changed files with 421 additions and 81 deletions
+1
View File
@@ -11,6 +11,7 @@ const site = process.env.PUBLIC_SITE_URL || 'https://windwiderstand.de';
export default defineConfig({
site,
devToolbar: { enabled: false },
integrations: [
svelte(),
sitemap(),
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Ico from "./Ico.svelte";
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">
<Ico 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>
+49
View File
@@ -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}
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "../lib/iconify-offline";
import Accordion from "./Accordion.svelte";
import { t, T, 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>
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "../lib/iconify-offline";
let {
icon,
class: cls = "",
}: {
icon: string;
class?: string;
} = $props();
</script>
<Icon {icon} class={cls} aria-hidden="true" />
+17 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { PostEntry } from "../lib/cms";
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
@@ -15,13 +16,23 @@
} = $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="aspect-3/2 w-full overflow-hidden bg-gray-100">
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
{#if resolvedImg}
<img
src={resolvedImg}
@@ -36,6 +47,11 @@
</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
+126 -68
View File
@@ -3100,8 +3100,13 @@ export interface components {
isActive?: boolean;
/** @description array items type string (list of strings) */
labels?: string[];
/** @description reference single slug to collection (e.g. img) */
mainImage?: string;
/** @description object image asset with src and alt text */
mainImage?: {
/** @description Alt text (optional) */
description?: string;
/** @description Asset URL */
src?: string;
};
/** @description object fixed sub-fields (key, value) */
meta?: {
key?: string;
@@ -3219,8 +3224,13 @@ export interface components {
isActive?: boolean;
/** @description array items type string (list of strings) */
labels?: string[];
/** @description reference single slug to collection (e.g. img) */
mainImage?: string;
/** @description object image asset with src and alt text */
mainImage?: {
/** @description Alt text (optional) */
description?: string;
/** @description Asset URL */
src?: string;
};
/** @description object fixed sub-fields (key, value) */
meta?: {
key?: string;
@@ -3417,11 +3427,11 @@ export interface components {
/** @description Banner headline */
headline?: string;
/** @description Banner image (optional) */
image?: string | {
/** @description Alt text / description for accessibility */
image?: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Internal name */
name: string;
@@ -3442,11 +3452,11 @@ export interface components {
/** @description Banner headline */
headline?: string;
/** @description Banner image (optional) */
image?: string | {
/** @description Alt text / description for accessibility */
image?: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Internal name */
name: string;
@@ -3676,8 +3686,13 @@ export interface components {
};
/** @description Internal component name */
name: string;
/** @description Optional overlay image (reference to img) */
overlayImage?: string;
/** @description Optional overlay image */
overlayImage?: {
/** @description Alt text (optional) */
description?: string;
/** @description Asset URL */
src?: string;
};
};
iframe_input: {
/** @description URL slug (used as filename) */
@@ -3718,8 +3733,13 @@ export interface components {
};
/** @description Internal component name */
name: string;
/** @description Optional overlay image (reference to img) */
overlayImage?: string;
/** @description Optional overlay image */
overlayImage?: {
/** @description Alt text (optional) */
description?: string;
/** @description Asset URL */
src?: string;
};
};
image: {
/** @description Entry identifier (filename) */
@@ -3732,12 +3752,12 @@ export interface components {
aspectRatio: "1:1" | "4:3" | "3:2" | "16:9" | "21:9" | "2:3" | "3:4";
/** @description Image caption */
caption?: string;
/** @description Image: Slug (Referenz) oder Inline-Objekt (gleiche Felder wie img) */
img: string | {
/** @description Alt text / description for accessibility */
/** @description Image asset */
img: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Column width (grid) */
layout?: {
@@ -3779,13 +3799,13 @@ export interface components {
readonly _slug?: string;
/** @description Gallery description (optional) */
description?: string;
/** @description Images (references or inline img) */
images: (string | {
/** @description Alt text / description for accessibility */
/** @description Bilder (Asset-URL + optionale Bildunterschrift) */
images: {
/** @description Bildunterschrift / Alt-Text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
/** @description Asset URL */
src: string;
})[];
}[];
/** @description Column width (grid). Optional. */
layout?: {
/**
@@ -3824,13 +3844,13 @@ export interface components {
_slug: string;
/** @description Gallery description (optional) */
description?: string;
/** @description Images (references or inline img) */
images: (string | {
/** @description Alt text / description for accessibility */
/** @description Bilder (Asset-URL + optionale Bildunterschrift) */
images: {
/** @description Bildunterschrift / Alt-Text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
/** @description Asset URL */
src: string;
})[];
}[];
/** @description Column width (grid). Optional. */
layout?: {
/**
@@ -3875,12 +3895,12 @@ export interface components {
aspectRatio: "1:1" | "4:3" | "3:2" | "16:9" | "21:9" | "2:3" | "3:4";
/** @description Image caption */
caption?: string;
/** @description Image: Slug (Referenz) oder Inline-Objekt (gleiche Felder wie img) */
img: string | {
/** @description Alt text / description for accessibility */
/** @description Image asset */
img: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Column width (grid) */
layout?: {
@@ -4269,11 +4289,11 @@ export interface components {
/** @description Optional link (website, contact, etc.) */
link?: string;
/** @description Optional logo image */
logo?: string | {
/** @description Alt text / description for accessibility */
logo?: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Organisation name */
name: string;
@@ -4288,11 +4308,11 @@ export interface components {
/** @description Optional link (website, contact, etc.) */
link?: string;
/** @description Optional logo image */
logo?: string | {
/** @description Alt text / description for accessibility */
logo?: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Organisation name */
name: string;
@@ -4424,13 +4444,15 @@ export interface components {
blogTagPageHeadline?: string;
/** @description Footer text (e.g. copyright) */
footerText1: string;
/** @description Logo image (reference or inline img) */
logo: string | {
/** @description Alt text / description for accessibility */
/** @description Logo image */
logo: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description HTML/JS snippet injected below each post. Use {{PAGE_ID}}, {{PAGE_URL}}, {{PAGE_TITLE}} as placeholders. */
postCommentSlot?: string;
/** @description Default meta description */
seoDescription: string;
/** @description Default SEO title for website */
@@ -4451,13 +4473,15 @@ export interface components {
blogTagPageHeadline?: string;
/** @description Footer text (e.g. copyright) */
footerText1: string;
/** @description Logo image (reference or inline img) */
logo: string | {
/** @description Alt text / description for accessibility */
/** @description Logo image */
logo: {
/** @description Alt text (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description HTML/JS snippet injected below each post. Use {{PAGE_ID}}, {{PAGE_URL}}, {{PAGE_TITLE}} as placeholders. */
postCommentSlot?: string;
/** @description Default meta description */
seoDescription: string;
/** @description Default SEO title for website */
@@ -4544,9 +4568,18 @@ export interface components {
readonly created?: string;
/**
* Format: date-time
* @description Optional display date
* @description Event date and time
*/
date?: string;
eventDate?: string;
/** @description Event location */
eventLocation?: {
/** @description Latitude (optional, for map) */
lat?: number;
/** @description Longitude (optional, for map) */
lng?: number;
/** @description Address / description (e.g. Town hall Schleusingen) */
text?: string;
};
/** @description Short summary for previews */
excerpt?: string;
/** @description Post headline */
@@ -4563,14 +4596,19 @@ export interface components {
* @default false
*/
important: boolean;
/**
* @description This post is an event
* @default false
*/
isEvent: boolean;
/** @description Display name (e.g. in navigation or link lists) */
linkName: string;
/** @description Featured image */
postImage?: string | {
/** @description Alt text / description for accessibility */
postImage?: {
/** @description Alt text / caption (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Associated tags */
postTag?: string[];
@@ -4641,9 +4679,18 @@ export interface components {
content: string;
/**
* Format: date-time
* @description Optional display date
* @description Event date and time
*/
date?: string;
eventDate?: string;
/** @description Event location */
eventLocation?: {
/** @description Latitude (optional, for map) */
lat?: number;
/** @description Longitude (optional, for map) */
lng?: number;
/** @description Address / description (e.g. Town hall Schleusingen) */
text?: string;
};
/** @description Short summary for previews */
excerpt?: string;
/** @description Post headline */
@@ -4660,14 +4707,19 @@ export interface components {
* @default false
*/
important: boolean;
/**
* @description This post is an event
* @default false
*/
isEvent: boolean;
/** @description Display name (e.g. in navigation or link lists) */
linkName: string;
/** @description Featured image */
postImage?: string | {
/** @description Alt text / description for accessibility */
postImage?: {
/** @description Alt text / caption (optional) */
description?: string;
/** @description Image URL (e.g. /api/assets/…). Use widget for picker. */
src: string;
/** @description Asset URL */
src?: string;
};
/** @description Associated tags */
postTag?: string[];
@@ -12516,6 +12568,8 @@ export interface operations {
blogPostsPageSubHeadline?: string;
/** @description Filter by blogTagPageHeadline */
blogTagPageHeadline?: string;
/** @description Filter by postCommentSlot */
postCommentSlot?: string;
};
header?: never;
path?: never;
@@ -12784,8 +12838,12 @@ export interface operations {
postTag?: string;
/** @description Filter by created */
created?: string;
/** @description Filter by date */
date?: string;
/** @description Filter by isEvent */
isEvent?: boolean;
/** @description Filter by eventDate */
eventDate?: string;
/** @description Filter by eventLocation */
eventLocation?: string;
/** @description Filter by icon */
icon?: string;
/** @description Filter by hideFromListing */
+105 -12
View File
@@ -3,7 +3,15 @@ import Layout from "../../layouts/Layout.astro";
import ContentRows from "../../components/ContentRows.svelte";
import Tag from "../../components/Tag.svelte";
import Tags from "../../components/Tags.svelte";
import { getPostSlugs, getPostBySlug } from "../../lib/cms";
import EventBadges from "../../components/EventBadges.svelte";
import EventMap from "../../components/EventMap.svelte";
import Accordion from "../../components/Accordion.svelte";
import {
getPostSlugs,
getPostBySlug,
getPageConfigBySlug,
getPageConfigs,
} from "../../lib/cms";
import type { PostEntry } from "../../lib/cms";
import {
resolveContentImages,
@@ -18,7 +26,7 @@ import {
formatPostDate,
} from "../../lib/blog-utils";
import { POST_RESOLVE } from "../../lib/constants";
import { T } from "../../lib/translations";
import { T, t } from "../../lib/translations";
export const prerender = true;
@@ -63,12 +71,53 @@ const postImageUrl = rawPostImageUrl
const postDate = formatPostDate(post.created ?? post.date);
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
const eventDateRaw = (post as { eventDate?: string }).eventDate;
const eventDateLabel =
isEvent && eventDateRaw
? new Date(eventDateRaw).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: null;
const eventLocation = isEvent
? (post as { eventLocation?: { text?: string; lat?: number; lng?: number } })
.eventLocation
: null;
const hasCoords = !!(eventLocation?.lat && eventLocation?.lng);
const _lat = eventLocation?.lat ?? 0;
const _lng = eventLocation?.lng ?? 0;
const mapEmbedUrl = hasCoords
? `https://www.openstreetmap.org/export/embed.html?bbox=${_lng - 0.01},${_lat - 0.007},${_lng + 0.01},${_lat + 0.007}&layer=mapnik&marker=${_lat},${_lng}`
: null;
const mapLinkUrl = hasCoords
? `https://www.openstreetmap.org/?mlat=${eventLocation!.lat}&mlon=${eventLocation!.lng}#map=15/${eventLocation!.lat}/${eventLocation!.lng}`
: eventLocation?.text
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
: null;
const contentHtml = await resolveBodyContent(post as { content?: unknown });
const hasRowContent =
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
const translations = Astro.locals.translations ?? {};
const pageConfig =
(await getPageConfigBySlug("page-config-default", { locale: "de" })) ??
(await getPageConfigs({ locale: "de", per_page: 1 }))[0] ??
null;
const commentSlotRaw =
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? "";
const commentSlot = commentSlotRaw
.replace(/\{\{PAGE_ID\}\}/g, slug)
.replace(/\{\{PAGE_URL\}\}/g, Astro.url.href)
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
---
<Layout
@@ -82,9 +131,22 @@ const translations = Astro.locals.translations ?? {};
breadcrumbItems={[
{ href: "/", translationKey: T.nav_start },
{ href: "/posts/", translationKey: T.nav_posts },
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
{
label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug,
},
]}
>
{
isEvent && (eventDateLabel || eventLocation?.text) && (
<div class="mb-6">
<EventBadges
eventDate={eventDateRaw ?? null}
locationText={eventLocation?.text ?? null}
locationHref={(mapEmbedUrl || mapLinkUrl) ? "#map-section" : null}
/>
</div>
)
}
<div class="md:flex gap-2 items-end">
{
postImageUrl && (
@@ -116,15 +178,46 @@ const translations = Astro.locals.translations ?? {};
</div>
</div>
</div>
<article class="mt-8">
{contentHtml && (
<div
class="content markdown max-w-none prose prose-zinc"
set:html={contentHtml}
/>
)}
{hasRowContent && (
<ContentRows client:load layout={post} translations={translations} />
)}
{
contentHtml && (
<div
class="content markdown max-w-none prose prose-zinc"
set:html={contentHtml}
/>
)
}
{
hasRowContent && (
<ContentRows client:load layout={post} translations={translations} />
)
}
</article>
{
isEvent && (mapEmbedUrl || mapLinkUrl) && (
<EventMap
client:load
embedUrl={mapEmbedUrl}
linkUrl={mapLinkUrl}
locationText={eventLocation?.text ?? null}
label={t(translations, T.post_map)}
translations={translations}
/>
)
}
{
(post as { showCommentSection?: boolean }).showCommentSection !== false &&
commentSlot && (
<Accordion label={t(translations, T.post_comments)} class="mt-6">
<div
class="p-8 border border-zinc-200 rounded-lg"
set:html={commentSlot}
/>
</Accordion>
)
}
</Layout>
+4
View File
@@ -334,6 +334,10 @@ main ol li {
@apply mt-4 mb-3;
}
.markdown hr {
@apply border-stein-200 border-dotted mb-4;
}
.markdown li,
.markdown li p {
@apply mt-0 mb-0;