Compare commits
5 Commits
5a1a49a97b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b87ad3c84 | |||
| 275f66fb8f | |||
| 28816bca87 | |||
| 0473b92201 | |||
| 57f4b226f9 |
@@ -20,6 +20,11 @@
|
||||
@apply rounded-lg border border-stein-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
/* Shared card hover: lift + shadow + wald border, used by all clickable card/tile/list-row components */
|
||||
.card-hover-lift {
|
||||
@apply transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md;
|
||||
}
|
||||
|
||||
/* Diagonal strikethrough for past calendar days */
|
||||
.day-past::after {
|
||||
content: '';
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
variants: {
|
||||
variant: {
|
||||
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
|
||||
tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
|
||||
tile: "card-hover-lift no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "tile" },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md"
|
||||
class="not-prose card-surface card-hover-lift relative flex h-full min-w-0 flex-col gap-2 overflow-hidden p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
|
||||
|
||||
@@ -8,12 +8,26 @@
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
|
||||
let {
|
||||
footer = null,
|
||||
translations = {},
|
||||
logoSvgHtml = null,
|
||||
logoUrl = null,
|
||||
siteName = "",
|
||||
}: {
|
||||
footer?: FooterEntry | null;
|
||||
translations?: Translations | null;
|
||||
logoSvgHtml?: string | null;
|
||||
logoUrl?: string | null;
|
||||
siteName?: string;
|
||||
} = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const hasRowContent = $derived(
|
||||
!!footer &&
|
||||
((footer.row1Content?.length ?? 0) > 0 ||
|
||||
@@ -32,13 +46,29 @@
|
||||
<div class="content-footer text-sm text-stein-200">
|
||||
<ContentRows layout={footer} translations={translations} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-0/80 text-center">
|
||||
{/if}
|
||||
|
||||
<!-- Bottom-Bar: Brand links, Copyright/Lizenz rechts (immer, unabhängig vom CMS-Content). -->
|
||||
<div
|
||||
class="mt-8 flex flex-col gap-4 border-t border-stein-700 pt-6 text-xs text-stein-400 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 text-stein-0">
|
||||
{#if logoSvgHtml}
|
||||
<span
|
||||
class="footer-logo inline-flex h-6 w-auto items-center [&_svg]:block [&_svg]:h-full [&_svg]:w-auto"
|
||||
aria-hidden="true">{@html logoSvgHtml}</span
|
||||
>
|
||||
{:else if logoUrl && logoUrl.trim() !== ""}
|
||||
<img src={logoUrl} alt="" class="h-6 w-auto object-contain" />
|
||||
{/if}
|
||||
<span class="font-bold">{siteName}</span>
|
||||
</div>
|
||||
<p class="md:text-right">
|
||||
{t(T.footer_copyright) !== T.footer_copyright
|
||||
? t(T.footer_copyright)
|
||||
: `© ${new Date().getFullYear()} Windwiderstand`}
|
||||
: `Copyright © ${year} · Inhalte frei nutzbar mit Quellenangabe „windwiderstand.de“ · CC BY 4.0`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
@@ -82,6 +112,21 @@
|
||||
.content-footer :global(h3) {
|
||||
color: var(--color-stein-0);
|
||||
}
|
||||
/* Link-Listen-Überschriften ("Seiten", "Kontakt & Community") als kleine
|
||||
Label-Kappen — überschreibt die unlayered h3-Defaults (daher !important). */
|
||||
.content-footer :global(.link-list h3) {
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.2 !important;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-stein-400) !important;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
/* Footer-Navigationslinks ohne Unterstrich (Markdown-Links bleiben unterstrichen). */
|
||||
.content-footer :global(.link-list a) {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.content-footer :global(.markdown),
|
||||
.content-footer :global(.prose) {
|
||||
color: var(--color-stein-200);
|
||||
|
||||
@@ -145,26 +145,26 @@
|
||||
{#each links as link}
|
||||
{@const active = isActiveTree(link, $page.url.pathname)}
|
||||
{#if link.children && link.children.length > 0}
|
||||
<div class="relative group">
|
||||
<div class="relative group inline-flex items-center">
|
||||
{#if link.href && link.href !== "#"}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{link.label}
|
||||
<Icon icon="lucide:chevron-down" class="size-3" aria-hidden="true" />
|
||||
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{link.label}
|
||||
<Icon icon="lucide:chevron-down" class="size-3" aria-hidden="true" />
|
||||
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
@@ -191,7 +191,7 @@
|
||||
{:else}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center {active ? 'font-bold ' : ''}"
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* InfoCard — Icon/Bild + Titel + Beschreibung + optionaler Link.
|
||||
* Wiederverwendbare Kachel für Sektions-Grids (siehe SectionGrid).
|
||||
* Wiederverwendbare Kachel für Sektions-Grids (siehe SectionGrid) UND als
|
||||
* Basis für spezialisierte Karten (OrganisationCard) via Snippet-Slots.
|
||||
*
|
||||
* `icon` ist entweder ein Iconify-String ("mdi:email") ODER eine Bild-URL
|
||||
* (https://… oder /…) — wird automatisch erkannt.
|
||||
* `tone`: default (weiß) oder green (grün getönt, z.B. Aside-Boxen).
|
||||
* `compact`: kleinere Maße + Icon inline neben dem Titel (spart Höhe).
|
||||
*
|
||||
* Erweiterungspunkte (halten die Basis konsistent, ohne Besonderheiten zu verlieren):
|
||||
* - `href` → ganze Karte wird Link (Tile-Hover wie Card).
|
||||
* - `media` → ersetzt das Standard-Icon-Badge (z.B. quadratisches Logo + Fallback).
|
||||
* - `badge` → absolut oben-rechts overlay.
|
||||
* - `footer` → am unteren Rand (z.B. Social-Chips).
|
||||
*/
|
||||
import type { Snippet } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
@@ -24,6 +32,12 @@
|
||||
external = false,
|
||||
tone = "default",
|
||||
compact = false,
|
||||
href = undefined,
|
||||
target = undefined,
|
||||
rel = undefined,
|
||||
media = undefined,
|
||||
badge = undefined,
|
||||
footer = undefined,
|
||||
}: {
|
||||
icon?: string;
|
||||
title: string;
|
||||
@@ -33,10 +47,25 @@
|
||||
external?: boolean;
|
||||
tone?: Tone;
|
||||
compact?: boolean;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
media?: Snippet;
|
||||
badge?: Snippet;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const isImage = $derived(!!icon && (/^https?:\/\//.test(icon) || icon.startsWith("/")));
|
||||
const hasLink = $derived(!!linkLabel && !!linkHref);
|
||||
|
||||
const shell = $derived(
|
||||
`relative flex h-full flex-col rounded-lg border ${compact ? "gap-2 p-4" : "gap-[14px] p-[22px]"} ${
|
||||
tone === "green" ? "border-wald-100 bg-wald-50" : "border-stein-200 bg-white"
|
||||
}`,
|
||||
);
|
||||
/* Klickbare Karte: Tile-Hover identisch zu Card.svelte (variant=tile), s. card-hover-lift in app.css. */
|
||||
const linkCls =
|
||||
"card-hover-lift no-underline text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2";
|
||||
</script>
|
||||
|
||||
{#snippet iconBadge(sizeClass: string, iconSize: string)}
|
||||
@@ -55,16 +84,18 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="flex h-full flex-col rounded-lg border {compact ? 'gap-2 p-4' : 'gap-[14px] p-[22px]'} {tone ===
|
||||
'green'
|
||||
? 'border-wald-100 bg-wald-50'
|
||||
: 'border-stein-200 bg-white'}"
|
||||
>
|
||||
{#snippet content()}
|
||||
{#if badge}
|
||||
<div class="absolute right-2 top-2 z-10">{@render badge()}</div>
|
||||
{/if}
|
||||
{#if compact}
|
||||
<!-- Kompakt: Icon hängt links, Text-Spalte (Titel/Desc/Link) rechts bündig. -->
|
||||
<div class="flex gap-2.5">
|
||||
{#if icon}<div class="pt-px">{@render iconBadge("size-5", "size-3")}</div>{/if}
|
||||
{#if media}
|
||||
<div class="pt-px">{@render media()}</div>
|
||||
{:else if icon}
|
||||
<div class="pt-px">{@render iconBadge("size-5", "size-3")}</div>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h3 class="text-sm! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
|
||||
{#if description}
|
||||
@@ -75,10 +106,17 @@
|
||||
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class="pt-0.5">{@render footer()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if icon}{@render iconBadge("size-11", "size-[23px]")}{/if}
|
||||
{#if media}
|
||||
{@render media()}
|
||||
{:else if icon}
|
||||
{@render iconBadge("size-11", "size-[23px]")}
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
|
||||
{#if description}
|
||||
@@ -90,5 +128,18 @@
|
||||
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class="mt-auto pt-1">{@render footer()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} {rel} class="{shell} {linkCls}">
|
||||
{@render content()}
|
||||
</a>
|
||||
{:else}
|
||||
<div class={shell}>
|
||||
{@render content()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import OrganisationCard from './OrganisationCard.svelte';
|
||||
import type { OrganisationEntry } from '$lib/block-types';
|
||||
|
||||
const LOGO =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const base: OrganisationEntry = {
|
||||
name: 'BI Steigerwald',
|
||||
description:
|
||||
'Bürgerinitiative gegen überdimensionierte Windkraftplanung im Steigerwald. Aktiv seit 2021.',
|
||||
link: { url: 'https://example.org' },
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/OrganisationCard',
|
||||
component: OrganisationCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
compact: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof OrganisationCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Logo aus CMS.
|
||||
export const MitLogo: Story = {
|
||||
args: { org: { ...base, logo: { src: LOGO } } },
|
||||
};
|
||||
|
||||
// Ohne Logo → Fisch-Fallback.
|
||||
export const FischFallback: Story = {
|
||||
args: { org: { ...base, logo: undefined } },
|
||||
};
|
||||
|
||||
// Badge oben rechts + Social-Chips.
|
||||
export const BadgeUndSocial: Story = {
|
||||
args: {
|
||||
org: {
|
||||
...base,
|
||||
logo: { src: LOGO },
|
||||
badge: { label: 'Neu', color: 'green' },
|
||||
whatsapp: 'https://chat.whatsapp.com/xyz',
|
||||
youtube: 'https://youtube.com/@example',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Kompakt: Logo links, kein Social, dichteres Grid.
|
||||
export const Kompakt: Story = {
|
||||
args: { org: { ...base, logo: { src: LOGO } }, compact: true },
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* OrganisationCard — Organisations-Kachel auf InfoCard-Basis.
|
||||
* Teilt Shell/Typo/Link-Hover mit InfoCard, behält aber die Besonderheiten:
|
||||
* - quadratisches Logo + Fisch-Fallback (statt rundem Icon) via media-Snippet
|
||||
* - Badge oben rechts
|
||||
* - Social-Chips (WhatsApp/YouTube) im footer
|
||||
* - ganze Karte klickbar (org.link)
|
||||
* `compact` = engere Variante (Logo links, keine Social-Chips) für dichte Grids.
|
||||
*/
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import InfoCard from "$lib/components/InfoCard.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import { extractCmsImageField } from "$lib/rusty-image";
|
||||
import type { OrganisationEntry } from "$lib/block-types";
|
||||
|
||||
let {
|
||||
org,
|
||||
compact = false,
|
||||
index = 0,
|
||||
}: { org: OrganisationEntry; compact?: boolean; index?: number } = $props();
|
||||
|
||||
const logoField = $derived(extractCmsImageField(org.logo));
|
||||
const alt = $derived(
|
||||
org.logo && typeof org.logo === "object" && org.logo.description ? org.logo.description : (org.name ?? ""),
|
||||
);
|
||||
const badge = $derived(org.badge && typeof org.badge === "object" ? org.badge : null);
|
||||
const href = $derived(
|
||||
typeof org.link === "object" && org.link !== null && typeof org.link.url === "string" && org.link.url.trim() !== ""
|
||||
? org.link.url.trim()
|
||||
: undefined,
|
||||
);
|
||||
const hasSocial = $derived(!compact && (!!org.whatsapp || !!org.youtube));
|
||||
const fishMaskId = $derived(`org-logo-fallback-${index}`);
|
||||
</script>
|
||||
|
||||
{#snippet media()}
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center overflow-hidden rounded-sm border border-stein-100 bg-stein-100 {compact
|
||||
? 'h-10 w-10'
|
||||
: 'h-12 w-12'}"
|
||||
>
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={96}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
{alt}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<svg
|
||||
class="text-stein-300 {compact ? 'h-7 w-7' : 'h-8 w-8'}"
|
||||
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>
|
||||
{/snippet}
|
||||
|
||||
{#snippet badgeSlot()}
|
||||
<Badge color={badge?.color as never}>{badge?.label}</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex gap-1.5">
|
||||
{#if org.whatsapp}
|
||||
<a
|
||||
href={org.whatsapp}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="WhatsApp"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-green-300 hover:bg-green-50 hover:text-green-700"
|
||||
>
|
||||
<Icon icon="mdi:whatsapp" class="size-3 shrink-0" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if org.youtube}
|
||||
<a
|
||||
href={org.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="YouTube"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Icon icon="lucide:youtube" class="size-3 shrink-0" />
|
||||
<span>YouTube</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<InfoCard
|
||||
{href}
|
||||
title={org.name ?? ""}
|
||||
description={org.description}
|
||||
{compact}
|
||||
{media}
|
||||
badge={badge?.label ? badgeSlot : undefined}
|
||||
footer={hasSocial ? footer : undefined}
|
||||
/>
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="group flex gap-3 overflow-hidden card-surface p-0 text-stein-900 no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
|
||||
class="group flex gap-3 overflow-hidden card-surface card-hover-lift p-0 text-stein-900 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div class="relative w-28 shrink-0 self-stretch overflow-hidden bg-stein-100 sm:w-32">
|
||||
{#if rawImg}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
const hasKickerRow = $derived(!!number || !!kicker);
|
||||
</script>
|
||||
|
||||
<div class="flex items-end justify-between gap-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
|
||||
<div>
|
||||
<!-- Kicker: Nummer · Linie · Label -->
|
||||
{#if hasKickerRow}
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<!-- Aktion (Slot hat Vorrang, sonst Label+Href) -->
|
||||
{#if hasAction}
|
||||
<div class="whitespace-nowrap">
|
||||
<div class="sm:whitespace-nowrap">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
||||
);
|
||||
|
||||
const eyebrowLabel = "Windwiderstand Thüringen";
|
||||
// Eyebrow überall — nur ausblenden, wenn er die Headline dupliziert (Home).
|
||||
const showEyebrow = $derived(
|
||||
(headline?.replace(/<[^>]*>/g, "").trim().toLowerCase() ?? "") !==
|
||||
eyebrowLabel.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasResponsive = $derived(
|
||||
responsive != null && responsive.fallback.trim().length > 0,
|
||||
);
|
||||
@@ -125,15 +132,31 @@
|
||||
{/key}
|
||||
{/if}
|
||||
{#if hasPageTitle}
|
||||
<div class="absolute inset-0 flex items-center justify-start ">
|
||||
<div class="container-custom mx-auto px-6 relative">
|
||||
{#if headlineHtml}
|
||||
<h1 class="bg-fire-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}
|
||||
<!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 flex items-center justify-start">
|
||||
<div class="container-custom mx-auto px-6">
|
||||
<div class="max-w-xl text-white">
|
||||
{#if showEyebrow}
|
||||
<span class="mb-3 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.08em] text-wald-200">
|
||||
<span class="h-px w-6 bg-wald-400"></span>
|
||||
{eyebrowLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if headlineHtml}
|
||||
<h1 class="text-4xl font-bold leading-[1.05] tracking-tight text-white! drop-shadow-sm lg:text-5xl">
|
||||
{@html headlineHtml}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if subheadlineHtml}
|
||||
<h2 class="mt-4 text-xl! font-normal! leading-snug text-stein-100! lg:text-2xl!">
|
||||
{@html subheadlineHtml}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import CalendarBlock from './CalendarBlock.svelte';
|
||||
// CalendarBlock liest Übersetzungen über die `translations`-Prop (nicht den
|
||||
// Context/WithTranslations-Decorator) → Fixture explizit reinreichen, sonst
|
||||
// rohe Keys (calendar_weekday_mo, calendar_all_upcoming, …).
|
||||
import translations from './_fixtures/translations.json';
|
||||
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/Calendar',
|
||||
@@ -39,5 +43,5 @@ const items = [
|
||||
];
|
||||
|
||||
export const Standard: Story = {
|
||||
args: { block: { title: 'Termine', variant: 'default', fromPost: false, items } },
|
||||
args: { block: { title: 'Termine', variant: 'default', fromPost: false, items }, translations },
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
import ImageModal from "$lib/components/ImageModal.svelte";
|
||||
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||
import QrModal from "$lib/components/QrModal.svelte";
|
||||
import EventDateBox from "./EventDateBox.svelte";
|
||||
|
||||
type EventCardItem = CalendarItemData & {
|
||||
start: Date;
|
||||
@@ -56,6 +55,8 @@
|
||||
let search = $state("");
|
||||
let currentMonth = $state(new Date());
|
||||
let selectedDay = $state<string | null>(null);
|
||||
/** Themenfilter-Popover unter der Kopfleiste (Icon-Button toggelt). */
|
||||
let tagFilterOpen = $state(false);
|
||||
|
||||
function parseDate(iso: string | undefined | null): Date | null {
|
||||
if (!iso) return null;
|
||||
@@ -289,6 +290,12 @@
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
/** Kurzer Wochentag ohne Punkt für die Datums-Kachel der Karte. */
|
||||
function fmtWd(d: Date): string {
|
||||
return d
|
||||
.toLocaleDateString("de-DE", { weekday: "short" })
|
||||
.replace(/\.$/, "");
|
||||
}
|
||||
function fmtGroupHeader(d: Date): string {
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
@@ -412,6 +419,20 @@
|
||||
return "later";
|
||||
}
|
||||
|
||||
/** Getönte Datums-Kachel je Urgency (ersetzt den früheren farbigen
|
||||
* Left-Border-Strich der Event-Rows). */
|
||||
function urgencyTile(u: Urgency): string {
|
||||
return (
|
||||
{
|
||||
now: "bg-fire-50 text-fire-700",
|
||||
today: "bg-fire-50 text-fire-600",
|
||||
tomorrow: "bg-erde-50 text-erde-700",
|
||||
week: "bg-himmel-50 text-himmel-700",
|
||||
later: "bg-stein-100 text-stein-600",
|
||||
}[u] ?? "bg-stein-100 text-stein-600"
|
||||
);
|
||||
}
|
||||
|
||||
function toICS(ev: EventCardItem): CalendarItemICS {
|
||||
return {
|
||||
title: ev.title || t(T.calendar_untitled),
|
||||
@@ -634,40 +655,58 @@
|
||||
locText ||
|
||||
item.isMultiDay
|
||||
)}
|
||||
<li
|
||||
id={eventId(item)}
|
||||
class="event-item {isNext ? 'event-item-next' : ''}"
|
||||
data-urgency={urgency}
|
||||
>
|
||||
<li id={eventId(item)} class="event-card">
|
||||
<details
|
||||
class="group/event"
|
||||
open={openByDefault || linkedSlug === eventId(item)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
|
||||
class="flex cursor-pointer list-none items-center gap-3 p-3 hover:bg-stein-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
|
||||
>
|
||||
<!-- Datum-Kachel: Wochentag/Tag/Monat (Einzel) bzw.
|
||||
Wochentag- und Tag-Range (mehrtägig). -->
|
||||
<EventDateBox
|
||||
start={item.start}
|
||||
end={item.end}
|
||||
multiDay={!!item.isMultiDay}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<!-- Datums-Kachel: Urgency-getönt, Einzeltag bzw.
|
||||
Tag-Range (mehrtägig). -->
|
||||
<div
|
||||
class="flex w-[50px] shrink-0 flex-col items-center rounded-lg py-1.5 {urgencyTile(
|
||||
urgency,
|
||||
)}"
|
||||
>
|
||||
<span class="text-[9px] font-semibold uppercase">
|
||||
{#if item.isMultiDay && item.end}{fmtWd(
|
||||
item.start,
|
||||
)}–{fmtWd(item.end)}{:else}{fmtWd(
|
||||
item.start,
|
||||
)}{/if}
|
||||
</span>
|
||||
<span
|
||||
class="font-bold leading-none {item.isMultiDay &&
|
||||
item.end
|
||||
? 'text-sm'
|
||||
: 'text-xl'}"
|
||||
>
|
||||
{#if item.isMultiDay && item.end}{item.start.getDate()}–{item.end.getDate()}{:else}{item.start.getDate()}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-semibold text-stein-900 leading-snug"
|
||||
class="text-sm font-semibold text-stein-900"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{#if live}
|
||||
<Badge color="fire" variant="solid" uppercase class="shrink-0">
|
||||
<Badge color="fire" variant="solid" uppercase>
|
||||
{live}
|
||||
</Badge>
|
||||
{:else if relativeDays(item.start)}
|
||||
<span
|
||||
class="rounded-full bg-stein-100 px-2 py-0.5 text-[10px] font-semibold text-stein-600"
|
||||
>
|
||||
{relativeDays(item.start)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600"
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stein-600"
|
||||
>
|
||||
{#if item.timeStr && !item.isMultiDay}
|
||||
<span
|
||||
@@ -683,7 +722,7 @@
|
||||
{/if}
|
||||
{#if locText}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 min-w-0"
|
||||
class="inline-flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:map-pin"
|
||||
@@ -693,21 +732,11 @@
|
||||
<span class="truncate">{locText}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.tags && item.tags.length > 0}
|
||||
{#each item.tags.slice(0, 2) as tg}
|
||||
<Badge color="blue">{tg}</Badge>
|
||||
{/each}
|
||||
{#if item.tags.length > 2}
|
||||
<span class="text-[10px] text-stein-500"
|
||||
>+{item.tags.length - 2}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="lucide:chevron-down"
|
||||
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open/event:rotate-180"
|
||||
class="size-4 shrink-0 text-stein-400 transition-transform group-open/event:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
@@ -959,154 +988,98 @@
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<!-- Filterleiste: Suche + „Heute" -->
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-stein-200 px-4 py-2">
|
||||
<!-- Kopfleiste: Suche (Pill) · Themenfilter (Icon-Button) · „Heute" (Chip).
|
||||
Themenfilter öffnet die Chips-Zeile darunter (tagFilterOpen). -->
|
||||
<div class="flex items-center gap-2 border-b border-stein-200 px-4 py-3">
|
||||
<SearchField
|
||||
bind:value={search}
|
||||
placeholder="Termine durchsuchen …"
|
||||
ariaLabel="Termine durchsuchen"
|
||||
class="min-w-0 flex-1"
|
||||
class="min-w-0 flex-1 rounded-full bg-stein-50"
|
||||
/>
|
||||
{#if allTags.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (tagFilterOpen = !tagFilterOpen)}
|
||||
class="relative grid size-9 shrink-0 place-items-center rounded-full text-stein-600 hover:bg-stein-100 {tagFilterOpen
|
||||
? 'bg-stein-100'
|
||||
: 'bg-stein-50'}"
|
||||
aria-label={t(T.calendar_filter_by_tag)}
|
||||
aria-pressed={!!activeTag}
|
||||
aria-expanded={tagFilterOpen}
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:sliders-horizontal"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{#if activeTag}
|
||||
<span
|
||||
class="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-himmel-600 ring-2 ring-stein-0"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToToday}
|
||||
class="chip inline-flex items-center gap-1.5 border-stein-300 text-stein-700 hover:bg-stein-100"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wald-50 px-3.5 py-2 text-xs font-semibold text-wald-700 hover:bg-wald-100"
|
||||
>
|
||||
<Icon icon="lucide:calendar-check" class="size-3.5" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="lucide:calendar-check"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if allTags.length > 0}
|
||||
<!-- Tag-Filter chips, einklappbar (kann bei vielen Themen lang werden).
|
||||
"Alle" = activeTag null. Aktiver Tag steht in der Summary, damit ein
|
||||
gesetzter Filter auch zugeklappt sichtbar bleibt. -->
|
||||
<details class="border-b border-stein-200 bg-stein-50 group/tags">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2 text-[11px] uppercase tracking-wide text-stein-500 hover:bg-stein-100"
|
||||
{#if allTags.length > 0 && tagFilterOpen}
|
||||
<!-- Themen-Chips: eingeblendet per Filter-Icon. "Alle" = activeTag null.
|
||||
Aktiver Tag zusätzlich als Punkt am Filter-Icon signalisiert. -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 border-b border-stein-200 bg-stein-50 px-4 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTag = null)}
|
||||
class="chip {!activeTag
|
||||
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||
aria-pressed={!activeTag}
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:chevron-right"
|
||||
class="size-3.5 transition-transform group-open/tags:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_filter_by_tag)}
|
||||
{#if activeTag}
|
||||
<Badge color="blue" variant="solid" class="ml-1 normal-case tracking-normal">
|
||||
{activeTag}
|
||||
</Badge>
|
||||
{/if}
|
||||
</summary>
|
||||
<div class="flex flex-wrap items-center gap-1.5 px-4 pb-2 pt-1">
|
||||
{t(T.calendar_all_tags)}
|
||||
</button>
|
||||
{#each allTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTag = null)}
|
||||
class="chip {!activeTag
|
||||
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
||||
class="chip {activeTag === tag
|
||||
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||
aria-pressed={!activeTag}
|
||||
aria-pressed={activeTag === tag}
|
||||
>
|
||||
{t(T.calendar_all_tags)}
|
||||
{tag}
|
||||
</button>
|
||||
{#each allTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
||||
class="chip {activeTag === tag
|
||||
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||
aria-pressed={activeTag === tag}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
|
||||
Apple: webcal:// Link → öffnet nativ Kalender.app / iOS Kalender.
|
||||
Alle anderen: direkter ICS-Download als Fallback. -->
|
||||
{#if feedHost}
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-stein-200 bg-himmel-100"
|
||||
>
|
||||
<Button
|
||||
href="/termin-melden"
|
||||
variant="warning"
|
||||
size="sm"
|
||||
class="min-h-[36px]"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
Termin melden
|
||||
</Button>
|
||||
{#if isApplePlatform}
|
||||
<Button
|
||||
href={webcalUrl}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label={t(T.calendar_subscribe_aria)}
|
||||
class="min-h-[36px]"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:calendar-plus"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_subscribe)}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
href={icsUrl}
|
||||
download="windwiderstand-termine.ics"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label={t(T.calendar_subscribe_aria)}
|
||||
class="min-h-[36px]"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:calendar-plus"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_subscribe)}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if items.length > 0}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={exportAll}
|
||||
class="min-h-[36px]"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:download"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_export_all)}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
|
||||
werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
|
||||
Tag-Grid. Klick auf Tag filtert Liste unten. -->
|
||||
<div
|
||||
bind:this={widgetEl}
|
||||
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
|
||||
>
|
||||
<div bind:this={widgetEl} class="calendar-widget bg-stein-0 p-4">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<span
|
||||
class="text-xl font-semibold text-himmel-900 capitalize whitespace-nowrap"
|
||||
class="text-xl font-semibold text-stein-900 capitalize whitespace-nowrap"
|
||||
>{monthLabel}</span
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
|
||||
class="p-2 rounded-md text-stein-500 hover:bg-stein-100 hover:text-stein-800 transition-colors"
|
||||
aria-label={t(T.calendar_prev_month)}
|
||||
onclick={prevMonth}
|
||||
>
|
||||
@@ -1118,7 +1091,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
|
||||
class="p-2 rounded-md text-stein-500 hover:bg-stein-100 hover:text-stein-800 transition-colors"
|
||||
aria-label={t(T.calendar_next_month)}
|
||||
onclick={nextMonth}
|
||||
>
|
||||
@@ -1131,43 +1104,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
|
||||
{#each weekdays as w, i}
|
||||
<div class="mb-1 grid grid-cols-7 text-center">
|
||||
{#each weekdays as w}
|
||||
<div
|
||||
class="py-1.5 text-xs font-bold rounded-sm {i >= 5
|
||||
? 'bg-stein-100/80 text-stein-600'
|
||||
: 'bg-himmel-200/50 text-himmel-800'}"
|
||||
class="py-1 text-[10px] font-semibold uppercase tracking-wide text-stein-400"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-0.5 text-center">
|
||||
{#each calendarDays as d}
|
||||
{#if d === null}
|
||||
<div class="h-10" aria-hidden="true"></div>
|
||||
<div class="h-9" aria-hidden="true"></div>
|
||||
{:else}
|
||||
{@const key = dayKey(d)}
|
||||
{@const isCurrentMonth = d.getMonth() === month}
|
||||
{@const dayEvents = eventsByDay.get(key) ?? []}
|
||||
{@const hasEvents = dayEvents.length > 0}
|
||||
{@const isSelected = selectedDay === key}
|
||||
{@const isToday = key === todayKey}
|
||||
{@const isFuture = key >= todayKey}
|
||||
{@const isWeekend =
|
||||
d.getDay() === 0 || d.getDay() === 6}
|
||||
{#if hasEvents}
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer font-semibold
|
||||
{!isFuture ? 'opacity-20 day-past' : ''}
|
||||
{isCurrentMonth
|
||||
? 'text-himmel-900'
|
||||
: 'text-himmel-700 opacity-60 hover:opacity-80'}
|
||||
{isWeekend
|
||||
? 'bg-erde-100 hover:bg-erde-200'
|
||||
: 'bg-himmel-200/80 hover:bg-himmel-300'}
|
||||
{isSelected
|
||||
? 'ring-2 ring-himmel-700 brightness-95'
|
||||
: ''}
|
||||
class="relative grid h-9 place-items-center rounded-lg text-[13px] font-bold cursor-pointer transition-colors
|
||||
{isFuture
|
||||
? 'text-wald-700 bg-wald-50 hover:bg-wald-100'
|
||||
: 'text-stein-400 bg-stein-50 hover:bg-stein-100'}
|
||||
{isSelected ? 'ring-2 ring-wald-500' : ''}
|
||||
{isToday ? 'today-cell' : ''}"
|
||||
onclick={() => selectDay(key)}
|
||||
onmouseenter={(e) => showTooltip(e, key)}
|
||||
@@ -1184,30 +1148,22 @@
|
||||
{ n: dayEvents.length },
|
||||
).replace(/\d+\s*/, '')}"
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
<span>{d.getDate()}</span>
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 sm:min-w-4.5 sm:h-4.5 px-0.5 rounded-full text-stein-0 text-[9px] sm:text-[10px] font-medium leading-none {isFuture
|
||||
? 'bg-wald-400'
|
||||
: 'bg-error'}"
|
||||
class="absolute bottom-1.5 size-[5px] rounded-full {isFuture
|
||||
? 'bg-wald-500'
|
||||
: 'bg-stein-400'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{dayEvents.length}
|
||||
</span>
|
||||
></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default
|
||||
{!isFuture ? 'opacity-20 day-past' : ''}
|
||||
{isWeekend
|
||||
? 'bg-stein-100/60'
|
||||
: 'bg-himmel-50/50'}
|
||||
{isCurrentMonth
|
||||
? 'text-himmel-800'
|
||||
: 'text-himmel-600 opacity-50'}
|
||||
class="grid h-9 place-items-center rounded-lg text-[13px]
|
||||
{isFuture ? 'text-stein-700' : 'text-stein-300'}
|
||||
{isToday ? 'today-cell' : ''}"
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
<span>{d.getDate()}</span>
|
||||
{#if isToday}
|
||||
<span class="sr-only"
|
||||
>{t(T.calendar_today_marker)}</span
|
||||
@@ -1393,6 +1349,51 @@
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Aktionsleiste: Termin melden · Abonnieren (Apple → webcal, sonst ICS)
|
||||
· Export. Ruhige Text+Icon-Links, nur nach mount (feedHost). -->
|
||||
{#if feedHost}
|
||||
<div
|
||||
class="flex items-center gap-4 border-t border-stein-100 px-4 py-3 text-xs font-semibold"
|
||||
>
|
||||
<a
|
||||
href="/termin-melden"
|
||||
class="inline-flex items-center gap-1.5 text-wald-700 no-underline hover:underline"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="size-3.5" aria-hidden="true" />
|
||||
Termin melden
|
||||
</a>
|
||||
<a
|
||||
href={isApplePlatform ? webcalUrl : icsUrl}
|
||||
download={isApplePlatform
|
||||
? undefined
|
||||
: "windwiderstand-termine.ics"}
|
||||
aria-label={t(T.calendar_subscribe_aria)}
|
||||
class="inline-flex items-center gap-1.5 text-himmel-700 no-underline hover:underline"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:calendar-plus"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_subscribe)}
|
||||
</a>
|
||||
{#if items.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={exportAll}
|
||||
class="ml-auto inline-flex items-center gap-1.5 text-stein-600 hover:text-stein-900"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:download"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(T.calendar_export_all)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if modalImage}
|
||||
@@ -1436,10 +1437,6 @@
|
||||
transparent
|
||||
);
|
||||
}
|
||||
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
|
||||
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator
|
||||
zwischen Tagesgruppen. So lassen sich Termine optisch klar
|
||||
auseinanderhalten ohne komplette Kacheloptik. */
|
||||
/* iOS Safari zeigt trotz list-style:none noch den Disclosure-Marker
|
||||
(dunkler Punkt oben-links der Kachel) → explizit ausblenden. */
|
||||
.calendar-block summary {
|
||||
@@ -1448,59 +1445,27 @@
|
||||
.calendar-block summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.calendar-block .event-item {
|
||||
position: relative;
|
||||
/* Event-Karten: eigenständige gerundete Karten mit Abstand statt
|
||||
Zebra + Left-Border. Urgency wandert in die getönte Datums-Kachel
|
||||
(urgencyTile), hier nur ruhige Card-Kante + Hover. */
|
||||
.calendar-block .event-card {
|
||||
list-style: none;
|
||||
border-left: 4px solid transparent;
|
||||
margin: 8px 12px;
|
||||
border: 1px solid var(--color-stein-200, #e0dcd5);
|
||||
border-radius: 10px;
|
||||
background: var(--color-stein-0, #fff);
|
||||
transition: background-color 150ms ease;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
/* iOS/WebKit zeigt sonst den <li>-Disc-Marker (dunkler Punkt links der
|
||||
Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */
|
||||
.calendar-block .event-item::marker {
|
||||
.calendar-block .event-card::marker {
|
||||
content: "";
|
||||
}
|
||||
.calendar-block .event-item + .event-item {
|
||||
border-top: 1px solid var(--color-stein-200, #e0dcd5);
|
||||
}
|
||||
/* Zebra: jeder zweite Termin in einer Tagesgruppe leicht abgedunkelt
|
||||
— sauber per :nth-of-type, ohne dass Day-Group-Header einen Slot
|
||||
verbraucht (event-item ist eigene <li>-Sequenz). */
|
||||
.calendar-block .event-item:nth-of-type(even) {
|
||||
background: var(--color-stein-50, #f7f5f1);
|
||||
}
|
||||
.calendar-block .event-item:hover {
|
||||
background: var(--color-himmel-50, #eef4f8);
|
||||
}
|
||||
/* Urgency-Akzent: 4px farbiger Strich links. Alphabetische
|
||||
Wichtigkeit: now (rot stark) → today (rot) → tomorrow (warm) →
|
||||
week (kühl) → later (neutral). */
|
||||
.calendar-block .event-item[data-urgency="now"] {
|
||||
border-left-color: var(--color-fire-700, #b91c1c);
|
||||
background: var(--color-fire-50, #fef2f2);
|
||||
}
|
||||
.calendar-block .event-item[data-urgency="today"] {
|
||||
border-left-color: var(--color-fire-500, #ef4444);
|
||||
}
|
||||
.calendar-block .event-item[data-urgency="tomorrow"] {
|
||||
border-left-color: var(--color-erde-500, #c97a3b);
|
||||
}
|
||||
.calendar-block .event-item[data-urgency="week"] {
|
||||
border-left-color: var(--color-himmel-500, #4798c4);
|
||||
}
|
||||
.calendar-block .event-item[data-urgency="later"] {
|
||||
border-left-color: var(--color-stein-300, #c4bfb4);
|
||||
}
|
||||
/* Next-up: kräftigere linke Kante + leichter himmel-Tint, plus
|
||||
ein subtiler ring damit der Eintrag beim Scroll-into-view
|
||||
visuell anspringt. */
|
||||
.calendar-block .event-item.event-item-next {
|
||||
border-left-width: 6px;
|
||||
box-shadow: inset 4px 0 0 var(--color-himmel-100, #d8e6ef);
|
||||
}
|
||||
/* Day-Group: dickerer Separator + etwas Luft zwischen Gruppen. */
|
||||
.calendar-block .day-group + .day-group {
|
||||
border-top: 4px solid var(--color-stein-100, #ede8df);
|
||||
.calendar-block .event-card:hover {
|
||||
border-color: var(--color-wald-300, #93d8ac);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.06);
|
||||
}
|
||||
/* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover.
|
||||
pointer-events:none damit kein mouseenter/leave Loop entsteht. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import CalendarCompactBlock from './CalendarCompactBlock.svelte';
|
||||
// Übersetzungen laufen über die `translations`-Prop (nicht Context), s. CalendarBlock.
|
||||
import translations from './_fixtures/translations.json';
|
||||
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/CalendarCompact',
|
||||
@@ -37,5 +39,6 @@ export const Standard: Story = {
|
||||
linkTo: { _slug: 'termine' },
|
||||
items,
|
||||
},
|
||||
translations,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
// addon-a11y's Live-Scan crasht in Docs-Mode bei echten fokussierbaren
|
||||
// <a>-Elementen (react-aria Cross-Iframe-Bug, Storybook 10). Files ist die
|
||||
// einzige Story mit echten Links → hier deaktivieren.
|
||||
parameters: { a11y: { test: 'off' } },
|
||||
args: {
|
||||
block: {
|
||||
number: '08',
|
||||
@@ -23,6 +27,7 @@ export const Standard: Story = {
|
||||
{
|
||||
src: 'https://cms.pm86.de/api/assets/files/beispiel.pdf?_environment=windwiderstand',
|
||||
title: 'Einwendung Vorlage',
|
||||
description: 'Zum Ausfüllen und Einreichen beim zuständigen Landratsamt',
|
||||
mime: 'application/pdf',
|
||||
size: 248000,
|
||||
filename: 'einwendung-vorlage.pdf',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import "$lib/iconify-offline";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
import type { FilesBlockData } from "$lib/block-types";
|
||||
import {
|
||||
extractCmsFileField,
|
||||
@@ -26,66 +25,9 @@
|
||||
label: string;
|
||||
description?: string;
|
||||
sizeLabel: string | null;
|
||||
ext: string | null;
|
||||
iconName: string;
|
||||
extLabel: string;
|
||||
};
|
||||
|
||||
function iconForExt(ext: string | null): string {
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return "lucide:file-text";
|
||||
case "doc":
|
||||
case "docx":
|
||||
case "rtf":
|
||||
case "odt":
|
||||
return "lucide:file-text";
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "ods":
|
||||
case "csv":
|
||||
case "tsv":
|
||||
return "lucide:file-spreadsheet";
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return "lucide:presentation";
|
||||
case "zip":
|
||||
case "tar":
|
||||
case "gz":
|
||||
case "7z":
|
||||
return "lucide:folder-archive";
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "webp":
|
||||
case "gif":
|
||||
case "svg":
|
||||
return "lucide:file-image";
|
||||
case "txt":
|
||||
case "md":
|
||||
return "lucide:file-text";
|
||||
case "json":
|
||||
return "lucide:braces";
|
||||
case "xml":
|
||||
case "html":
|
||||
case "htm":
|
||||
return "lucide:code";
|
||||
case "py":
|
||||
return "mdi:language-python";
|
||||
case "js":
|
||||
case "ts":
|
||||
case "tsx":
|
||||
case "jsx":
|
||||
return "mdi:language-javascript";
|
||||
case "rs":
|
||||
return "mdi:language-rust";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "lucide:terminal";
|
||||
default:
|
||||
return "lucide:file";
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = $derived(
|
||||
((block.items ?? []) as unknown[])
|
||||
.map((raw): Resolved | null => {
|
||||
@@ -103,8 +45,7 @@
|
||||
label,
|
||||
description: field.description,
|
||||
sizeLabel: formatFileSize(field.size),
|
||||
ext,
|
||||
iconName: iconForExt(ext),
|
||||
extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
|
||||
};
|
||||
})
|
||||
.filter((r): r is Resolved => r !== null),
|
||||
@@ -130,27 +71,35 @@
|
||||
{#if resolved.length === 0}
|
||||
<p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1 list-none! p-0 m-0! ml-0">
|
||||
<ul class="flex flex-col gap-3 list-none! p-0 m-0! ml-0">
|
||||
{#each resolved as f}
|
||||
<li class="list-none! ml-0">
|
||||
<a
|
||||
href={f.href}
|
||||
class="group flex items-start gap-2 rounded-lg border border-stein-200 bg-white px-2.5 py-1.5 hover:border-stein-400 hover:bg-stein-50 transition-colors no-underline"
|
||||
class="group card-hover-lift flex items-center gap-3 rounded-xl border border-stein-200 bg-white px-3.5 py-2.5 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={f.iconName} class="size-5 shrink-0 text-stein-500 mt-0.5" aria-hidden="true" />
|
||||
<span
|
||||
class="shrink-0 size-9 flex items-center justify-center rounded-lg bg-fire-50 text-[10px] font-bold tracking-wide text-fire-700"
|
||||
>{f.extLabel}</span
|
||||
>
|
||||
<span class="flex-1 min-w-0 flex flex-col">
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-sm text-stein-900 group-hover:underline truncate">{f.label}</span>
|
||||
<span class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span class="text-sm text-stein-900 truncate">{f.label}</span>
|
||||
{#if f.sizeLabel}
|
||||
<Badge class="tabular-nums">{f.sizeLabel}</Badge>
|
||||
<span class="shrink-0 text-xs text-stein-400 tabular-nums">({f.sizeLabel})</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if f.description}
|
||||
<span class="text-xs text-stein-400">{f.description}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Icon
|
||||
icon="lucide:download"
|
||||
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -4,27 +4,13 @@
|
||||
import "$lib/iconify-offline";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
|
||||
import { extractCmsImageField } from "$lib/rusty-image";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||
import SectionGrid from "$lib/components/SectionGrid.svelte";
|
||||
import InfoCard from "$lib/components/InfoCard.svelte";
|
||||
import OrganisationCard from "$lib/components/OrganisationCard.svelte";
|
||||
import IconButton from "$lib/components/IconButton.svelte";
|
||||
|
||||
function organisationLogoField(o: OrganisationEntry) {
|
||||
return extractCmsImageField(o.logo);
|
||||
}
|
||||
|
||||
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 actionRef = $derived(
|
||||
block.action && typeof block.action === "object" ? block.action : null,
|
||||
@@ -71,78 +57,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet defaultCard(o: OrganisationEntry, linkUrl: string | undefined, logoField: ReturnType<typeof organisationLogoField>, orgIndex: number)}
|
||||
<Card href={linkUrl} class="h-full">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-stein-100 bg-stein-100">
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={96}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
||||
<svg class="h-8 w-8 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-2 right-2">
|
||||
<Badge color={o.badge.color}>{o.badge.label}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
<h5 class="min-w-0 truncate text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
|
||||
{#if o.description}
|
||||
<p class="line-clamp-2 text-xs text-stein-500">{o.description}</p>
|
||||
{/if}
|
||||
{#if o.whatsapp || o.youtube}
|
||||
<div class="mt-auto flex gap-1.5 pt-1">
|
||||
{#if o.whatsapp}
|
||||
<a
|
||||
href={o.whatsapp}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="WhatsApp"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-green-300 hover:bg-green-50 hover:text-green-700"
|
||||
>
|
||||
<Icon icon="mdi:whatsapp" class="size-3 shrink-0" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if o.youtube}
|
||||
<a
|
||||
href={o.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="YouTube"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Icon icon="lucide:youtube" class="size-3 shrink-0" />
|
||||
<span>YouTube</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ctaCard()}
|
||||
<Card
|
||||
@@ -162,7 +76,8 @@
|
||||
</div>
|
||||
{#if addOrganisationCtaLink}
|
||||
<div class="self-start md:self-center md:shrink-0">
|
||||
<Button variant="primary" size="sm">
|
||||
<Button variant="primary" size="md">
|
||||
<Icon icon="lucide:users" class="size-4 shrink-0" aria-hidden="true" />
|
||||
{addOrganisationCtaLink.linkName}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -198,15 +113,9 @@
|
||||
actionHref={actionRef?.url}
|
||||
columns={3}
|
||||
>
|
||||
{#each orgs as org}
|
||||
{#each orgs as org, orgIndex}
|
||||
{#if typeof org === "object" && org !== null}
|
||||
{@const o = org as OrganisationEntry}
|
||||
{@const logoField = organisationLogoField(o)}
|
||||
<InfoCard
|
||||
icon={logoField?.url ?? "mdi:account-group"}
|
||||
title={o.name ?? ""}
|
||||
description={o.description}
|
||||
/>
|
||||
<OrganisationCard org={org as OrganisationEntry} index={orgIndex} />
|
||||
{/if}
|
||||
{/each}
|
||||
</SectionGrid>
|
||||
@@ -218,19 +127,10 @@
|
||||
>
|
||||
{#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 logoField = organisationLogoField(o)}
|
||||
<div
|
||||
class="shrink-0 snap-start w-[calc((100%-0.5rem)/1.5)] sm:w-[calc((100%-1rem)/2)] lg:w-[calc((100%-2rem)/3)]"
|
||||
>
|
||||
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
|
||||
<OrganisationCard org={org as OrganisationEntry} index={orgIndex} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -253,56 +153,7 @@
|
||||
>
|
||||
{#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 logoField = organisationLogoField(o)}
|
||||
{#if compact}
|
||||
<Card href={linkUrl} layout="inline" class="h-full">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden bg-stein-100">
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={80}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
||||
<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="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h5 class="text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
|
||||
{#if o.description}
|
||||
<p class="line-clamp-2 text-xs font-light text-stein-600">{o.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
|
||||
{/if}
|
||||
<OrganisationCard org={org as OrganisationEntry} {compact} index={orgIndex} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
|
||||
@@ -124,13 +124,19 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-md">
|
||||
<h3 class="text-stein-0 text-base font-semibold">{t(T.newsletter_inline_heading)}</h3>
|
||||
<div class="grid gap-6 md:grid-cols-2 md:items-center md:gap-12">
|
||||
<div>
|
||||
<h3 class="text-stein-0 text-2xl! font-bold! leading-tight">{t(T.newsletter_inline_heading)}</h3>
|
||||
{#if t(T.newsletter_inline_desc) !== T.newsletter_inline_desc}
|
||||
<p class="mt-2 max-w-sm text-sm text-stein-400">{t(T.newsletter_inline_desc)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if formState === "success"}
|
||||
<p class="mt-2 text-sm text-wald-200">{t(T.newsletter_success_body)}</p>
|
||||
<p class="text-sm text-wald-200">{t(T.newsletter_success_body)}</p>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} novalidate class="mt-3" aria-busy={formState === "pending"}>
|
||||
<form onsubmit={handleSubmit} novalidate aria-busy={formState === "pending"}>
|
||||
<!-- Honeypots -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0">
|
||||
<input name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||
@@ -178,4 +184,5 @@
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const POST_RESOLVE = ["all"];
|
||||
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
|
||||
|
||||
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
|
||||
export const SITE_NAME = "Windwiderstand";
|
||||
export const SITE_NAME = "Windwiderstand Thüringen";
|
||||
|
||||
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
|
||||
export const TRANSLATION_BUNDLE_SLUG = "app";
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"calendar-plus": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 19h6M16 2v4m3 10v6m2-9.402V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5M3 10h18M8 2v4\"/>"
|
||||
},
|
||||
"youtube": {
|
||||
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M2.5 17a24.1 24.1 0 0 1 0-10a2 2 0 0 1 1.4-1.4a49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10a2 2 0 0 1-1.4 1.4a49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17\"/><path d=\"m10 15l5-3l-5-3z\"/></g>",
|
||||
"hidden": true
|
||||
},
|
||||
"message-circle": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092a10 10 0 1 0-4.777-4.719\"/>"
|
||||
},
|
||||
@@ -150,10 +154,6 @@
|
||||
"plus": {
|
||||
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14m-7-7v14\"/>"
|
||||
},
|
||||
"youtube": {
|
||||
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M2.5 17a24.1 24.1 0 0 1 0-10a2 2 0 0 1 1.4-1.4a49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10a2 2 0 0 1-1.4 1.4a49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17\"/><path d=\"m10 15l5-3l-5-3z\"/></g>",
|
||||
"hidden": true
|
||||
},
|
||||
"alarm-clock": {
|
||||
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><circle cx=\"12\" cy=\"13\" r=\"8\"/><path d=\"M12 9v4l2 2M5 3L2 6m20 0l-3-3M6.38 18.7L4 21m13.64-2.33L20 21\"/></g>"
|
||||
},
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"icons": {
|
||||
"email-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z\"/>"
|
||||
},
|
||||
"whatsapp": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12.04 2c-5.46 0-9.91 4.45-9.91 9.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38c1.45.79 3.08 1.21 4.74 1.21c5.46 0 9.91-4.45 9.91-9.91c0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2m.01 1.67c2.2 0 4.26.86 5.82 2.42a8.23 8.23 0 0 1 2.41 5.83c0 4.54-3.7 8.23-8.24 8.23c-1.48 0-2.93-.39-4.19-1.15l-.3-.17l-3.12.82l.83-3.04l-.2-.32a8.2 8.2 0 0 1-1.26-4.38c.01-4.54 3.7-8.24 8.25-8.24M8.53 7.33c-.16 0-.43.06-.66.31c-.22.25-.87.86-.87 2.07c0 1.22.89 2.39 1 2.56c.14.17 1.76 2.67 4.25 3.73c.59.27 1.05.42 1.41.53c.59.19 1.13.16 1.56.1c.48-.07 1.46-.6 1.67-1.18s.21-1.07.15-1.18c-.07-.1-.23-.16-.48-.27c-.25-.14-1.47-.74-1.69-.82c-.23-.08-.37-.12-.56.12c-.16.25-.64.81-.78.97c-.15.17-.29.19-.53.07c-.26-.13-1.06-.39-2-1.23c-.74-.66-1.23-1.47-1.38-1.72c-.12-.24-.01-.39.11-.5c.11-.11.27-.29.37-.44c.13-.14.17-.25.25-.41c.08-.17.04-.31-.02-.43c-.06-.11-.56-1.35-.77-1.84c-.2-.48-.4-.42-.56-.43c-.14 0-.3-.01-.47-.01\"/>"
|
||||
},
|
||||
"email-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z\"/>"
|
||||
},
|
||||
"youtube": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m10 15l5.19-3L10 9zm11.56-7.83c.13.47.22 1.1.28 1.9c.07.8.1 1.49.1 2.09L22 12c0 2.19-.16 3.8-.44 4.83c-.25.9-.83 1.48-1.73 1.73c-.47.13-1.33.22-2.65.28c-1.3.07-2.49.1-3.59.1L12 19c-4.19 0-6.8-.16-7.83-.44c-.9-.25-1.48-.83-1.73-1.73c-.13-.47-.22-1.1-.28-1.9c-.07-.8-.1-1.49-.1-2.09L2 12c0-2.19.16-3.8.44-4.83c.25-.9.83-1.48 1.73-1.73c.47-.13 1.33-.22 2.65-.28c1.3-.07 2.49-.1 3.59-.1L12 5c4.19 0 6.8.16 7.83.44c.9.25 1.48.83 1.73 1.73\"/>"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@ const TRANSLATION_KEYS = [
|
||||
"newsletter_error_email_max", // {{n}}
|
||||
"newsletter_error_consent_required",
|
||||
"newsletter_inline_heading",
|
||||
"newsletter_inline_desc",
|
||||
"newsletter_inline_consent",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -408,7 +408,13 @@
|
||||
</main>
|
||||
|
||||
<div class="print:hidden">
|
||||
<Footer footer={data.footerData} translations={data.translations} />
|
||||
<Footer
|
||||
footer={data.footerData}
|
||||
translations={data.translations}
|
||||
logoSvgHtml={data.logoSvgHtml}
|
||||
logoUrl={data.logoUrl}
|
||||
siteName={data.siteName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user