CalendarBlock: ruhigere, editorialere UI-Überarbeitung
Deploy / verify (push) Successful in 1m31s
Deploy / deploy (push) Successful in 1m36s

- Kopfleiste zusammengefasst: Suche-Pill + Themenfilter-Icon + Heute-Chip
- Monats-Grid neutralisiert: weisser Grund, Punkt-Marker statt Zahl-Badge
- Terminliste als Karten statt Zebra-Rows; Urgency in getönte Datums-Kachel
- Aktionsleiste ans Card-Ende verschoben, ruhige Text+Icon-Links

Enthält zusätzlich bestehende lokale Änderungen (Card, Header, ContactCard,
InfoCard, PostCardCompact, FilesBlock, app.css, Storybook-Fixtures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-12 23:12:44 +02:00
parent 28816bca87
commit 275f66fb8f
11 changed files with 242 additions and 311 deletions
+5
View File
@@ -20,6 +20,11 @@
@apply rounded-lg border border-stein-200 bg-white shadow-sm; @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 */ /* Diagonal strikethrough for past calendar days */
.day-past::after { .day-past::after {
content: ''; content: '';
+1 -1
View File
@@ -44,7 +44,7 @@
variants: { variants: {
variant: { variant: {
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100", 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" }, defaultVariants: { variant: "tile" },
+1 -1
View File
@@ -16,7 +16,7 @@
</script> </script>
<div <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"> <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> <p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
+6 -6
View File
@@ -145,26 +145,26 @@
{#each links as link} {#each links as link}
{@const active = isActiveTree(link, $page.url.pathname)} {@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0} {#if link.children && link.children.length > 0}
<div class="relative group"> <div class="relative group inline-flex items-center">
{#if link.href && link.href !== "#"} {#if link.href && link.href !== "#"}
<a <a
href={link.href} 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-haspopup="menu"
aria-expanded="false" aria-expanded="false"
> >
{link.label} {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> </a>
{:else} {:else}
<button <button
type="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-haspopup="menu"
aria-expanded="false" aria-expanded="false"
> >
{link.label} {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> </button>
{/if} {/if}
<div <div
@@ -191,7 +191,7 @@
{:else} {:else}
<a <a
href={link.href} 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} aria-current={active ? "page" : undefined}
> >
{link.label} {link.label}
+2 -2
View File
@@ -63,9 +63,9 @@
tone === "green" ? "border-wald-100 bg-wald-50" : "border-stein-200 bg-white" tone === "green" ? "border-wald-100 bg-wald-50" : "border-stein-200 bg-white"
}`, }`,
); );
/* Klickbare Karte: Tile-Hover identisch zu Card.svelte (variant=tile). */ /* Klickbare Karte: Tile-Hover identisch zu Card.svelte (variant=tile), s. card-hover-lift in app.css. */
const linkCls = const linkCls =
"transition-[transform,box-shadow,border-color] duration-200 no-underline text-inherit 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"; "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> </script>
{#snippet iconBadge(sizeClass: string, iconSize: string)} {#snippet iconBadge(sizeClass: string, iconSize: string)}
+1 -1
View File
@@ -38,7 +38,7 @@
<a <a
{href} {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"> <div class="relative w-28 shrink-0 self-stretch overflow-hidden bg-stein-100 sm:w-32">
{#if rawImg} {#if rawImg}
@@ -1,5 +1,9 @@
import type { Meta, StoryObj } from '@storybook/sveltekit'; import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarBlock from './CalendarBlock.svelte'; 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 = { const meta = {
title: 'CMS-Blocks/Calendar', title: 'CMS-Blocks/Calendar',
@@ -39,5 +43,5 @@ const items = [
]; ];
export const Standard: Story = { export const Standard: Story = {
args: { block: { title: 'Termine', variant: 'default', fromPost: false, items } }, args: { block: { title: 'Termine', variant: 'default', fromPost: false, items }, translations },
}; };
+197 -232
View File
@@ -21,7 +21,6 @@
import ImageModal from "$lib/components/ImageModal.svelte"; import ImageModal from "$lib/components/ImageModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte"; import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import QrModal from "$lib/components/QrModal.svelte"; import QrModal from "$lib/components/QrModal.svelte";
import EventDateBox from "./EventDateBox.svelte";
type EventCardItem = CalendarItemData & { type EventCardItem = CalendarItemData & {
start: Date; start: Date;
@@ -56,6 +55,8 @@
let search = $state(""); let search = $state("");
let currentMonth = $state(new Date()); let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null); 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 { function parseDate(iso: string | undefined | null): Date | null {
if (!iso) return null; if (!iso) return null;
@@ -289,6 +290,12 @@
month: "short", 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 { function fmtGroupHeader(d: Date): string {
return d.toLocaleDateString("de-DE", { return d.toLocaleDateString("de-DE", {
weekday: "long", weekday: "long",
@@ -412,6 +419,20 @@
return "later"; 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 { function toICS(ev: EventCardItem): CalendarItemICS {
return { return {
title: ev.title || t(T.calendar_untitled), title: ev.title || t(T.calendar_untitled),
@@ -634,40 +655,58 @@
locText || locText ||
item.isMultiDay item.isMultiDay
)} )}
<li <li id={eventId(item)} class="event-card">
id={eventId(item)}
class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency}
>
<details <details
class="group/event" class="group/event"
open={openByDefault || linkedSlug === eventId(item)} open={openByDefault || linkedSlug === eventId(item)}
> >
<summary <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. <!-- Datums-Kachel: Urgency-getönt, Einzeltag bzw.
Wochentag- und Tag-Range (mehrtägig). --> Tag-Range (mehrtägig). -->
<EventDateBox <div
start={item.start} class="flex w-[50px] shrink-0 flex-col items-center rounded-lg py-1.5 {urgencyTile(
end={item.end} urgency,
multiDay={!!item.isMultiDay} )}"
/> >
<div class="flex-1 min-w-0"> <span class="text-[9px] font-semibold uppercase">
<div class="flex items-baseline gap-2 flex-wrap"> {#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 <span
class="text-sm font-semibold text-stein-900 leading-snug" class="text-sm font-semibold text-stein-900"
> >
{title} {title}
</span> </span>
{#if live} {#if live}
<Badge color="fire" variant="solid" uppercase class="shrink-0"> <Badge color="fire" variant="solid" uppercase>
{live} {live}
</Badge> </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} {/if}
</div> </div>
<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} {#if item.timeStr && !item.isMultiDay}
<span <span
@@ -683,7 +722,7 @@
{/if} {/if}
{#if locText} {#if locText}
<span <span
class="inline-flex items-center gap-1 min-w-0" class="inline-flex min-w-0 items-center gap-1"
> >
<Icon <Icon
icon="lucide:map-pin" icon="lucide:map-pin"
@@ -693,21 +732,11 @@
<span class="truncate">{locText}</span> <span class="truncate">{locText}</span>
</span> </span>
{/if} {/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>
</div> </div>
<Icon <Icon
icon="lucide:chevron-down" 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" aria-hidden="true"
/> />
</summary> </summary>
@@ -959,154 +988,98 @@
</h3> </h3>
{/if} {/if}
<!-- Filterleiste: Suche + „Heute" --> <!-- Kopfleiste: Suche (Pill) · Themenfilter (Icon-Button) · „Heute" (Chip).
<div class="flex flex-wrap items-center gap-2 border-b border-stein-200 px-4 py-2"> Themenfilter öffnet die Chips-Zeile darunter (tagFilterOpen). -->
<div class="flex items-center gap-2 border-b border-stein-200 px-4 py-3">
<SearchField <SearchField
bind:value={search} bind:value={search}
placeholder="Termine durchsuchen …" placeholder="Termine durchsuchen …"
ariaLabel="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 <button
type="button" type="button"
onclick={goToToday} 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 Heute
</button> </button>
</div> </div>
{#if allTags.length > 0} {#if allTags.length > 0 && tagFilterOpen}
<!-- Tag-Filter chips, einklappbar (kann bei vielen Themen lang werden). <!-- Themen-Chips: eingeblendet per Filter-Icon. "Alle" = activeTag null.
"Alle" = activeTag null. Aktiver Tag steht in der Summary, damit ein Aktiver Tag zusätzlich als Punkt am Filter-Icon signalisiert. -->
gesetzter Filter auch zugeklappt sichtbar bleibt. --> <div
<details class="border-b border-stein-200 bg-stein-50 group/tags"> class="flex flex-wrap items-center gap-1.5 border-b border-stein-200 bg-stein-50 px-4 py-2"
<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" <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 {t(T.calendar_all_tags)}
icon="lucide:chevron-right" </button>
class="size-3.5 transition-transform group-open/tags:rotate-90" {#each allTags as tag}
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">
<button <button
type="button" type="button"
onclick={() => (activeTag = null)} onclick={() => (activeTag = activeTag === tag ? null : tag)}
class="chip {!activeTag class="chip {activeTag === tag
? 'bg-himmel-700 text-himmel-50 border-himmel-700' ? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}" : 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={!activeTag} aria-pressed={activeTag === tag}
> >
{t(T.calendar_all_tags)} {tag}
</button> </button>
{#each allTags as tag} {/each}
<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}
</div> </div>
{/if} {/if}
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst <!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
werden aspect-square cells auf Desktop gigantisch). Monat-Nav + werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
Tag-Grid. Klick auf Tag filtert Liste unten. --> Tag-Grid. Klick auf Tag filtert Liste unten. -->
<div <div bind:this={widgetEl} class="calendar-widget bg-stein-0 p-4">
bind:this={widgetEl}
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
>
<div class="w-full"> <div class="w-full">
<div class="flex items-center justify-between gap-2 mb-2"> <div class="flex items-center justify-between gap-2 mb-2">
<span <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 >{monthLabel}</span
> >
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
type="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)} aria-label={t(T.calendar_prev_month)}
onclick={prevMonth} onclick={prevMonth}
> >
@@ -1118,7 +1091,7 @@
</button> </button>
<button <button
type="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)} aria-label={t(T.calendar_next_month)}
onclick={nextMonth} onclick={nextMonth}
> >
@@ -1131,43 +1104,34 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-7 gap-1.5 text-center text-sm"> <div class="mb-1 grid grid-cols-7 text-center">
{#each weekdays as w, i} {#each weekdays as w}
<div <div
class="py-1.5 text-xs font-bold rounded-sm {i >= 5 class="py-1 text-[10px] font-semibold uppercase tracking-wide text-stein-400"
? 'bg-stein-100/80 text-stein-600'
: 'bg-himmel-200/50 text-himmel-800'}"
> >
{w} {w}
</div> </div>
{/each} {/each}
</div>
<div class="grid grid-cols-7 gap-0.5 text-center">
{#each calendarDays as d} {#each calendarDays as d}
{#if d === null} {#if d === null}
<div class="h-10" aria-hidden="true"></div> <div class="h-9" aria-hidden="true"></div>
{:else} {:else}
{@const key = dayKey(d)} {@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const dayEvents = eventsByDay.get(key) ?? []} {@const dayEvents = eventsByDay.get(key) ?? []}
{@const hasEvents = dayEvents.length > 0} {@const hasEvents = dayEvents.length > 0}
{@const isSelected = selectedDay === key} {@const isSelected = selectedDay === key}
{@const isToday = key === todayKey} {@const isToday = key === todayKey}
{@const isFuture = key >= todayKey} {@const isFuture = key >= todayKey}
{@const isWeekend =
d.getDay() === 0 || d.getDay() === 6}
{#if hasEvents} {#if hasEvents}
<button <button
type="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 class="relative grid h-9 place-items-center rounded-lg text-[13px] font-bold cursor-pointer transition-colors
{!isFuture ? 'opacity-20 day-past' : ''} {isFuture
{isCurrentMonth ? 'text-wald-700 bg-wald-50 hover:bg-wald-100'
? 'text-himmel-900' : 'text-stein-400 bg-stein-50 hover:bg-stein-100'}
: 'text-himmel-700 opacity-60 hover:opacity-80'} {isSelected ? 'ring-2 ring-wald-500' : ''}
{isWeekend
? 'bg-erde-100 hover:bg-erde-200'
: 'bg-himmel-200/80 hover:bg-himmel-300'}
{isSelected
? 'ring-2 ring-himmel-700 brightness-95'
: ''}
{isToday ? 'today-cell' : ''}" {isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)} onclick={() => selectDay(key)}
onmouseenter={(e) => showTooltip(e, key)} onmouseenter={(e) => showTooltip(e, key)}
@@ -1184,30 +1148,22 @@
{ n: dayEvents.length }, { n: dayEvents.length },
).replace(/\d+\s*/, '')}" ).replace(/\d+\s*/, '')}"
> >
<span class="text-xs">{d.getDate()}</span> <span>{d.getDate()}</span>
<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 class="absolute bottom-1.5 size-[5px] rounded-full {isFuture
? 'bg-wald-400' ? 'bg-wald-500'
: 'bg-error'}" : 'bg-stein-400'}"
aria-hidden="true" aria-hidden="true"
> ></span>
{dayEvents.length}
</span>
</button> </button>
{:else} {:else}
<div <div
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default class="grid h-9 place-items-center rounded-lg text-[13px]
{!isFuture ? 'opacity-20 day-past' : ''} {isFuture ? 'text-stein-700' : 'text-stein-300'}
{isWeekend
? 'bg-stein-100/60'
: 'bg-himmel-50/50'}
{isCurrentMonth
? 'text-himmel-800'
: 'text-himmel-600 opacity-50'}
{isToday ? 'today-cell' : ''}" {isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined} aria-current={isToday ? "date" : undefined}
> >
<span class="text-xs">{d.getDate()}</span> <span>{d.getDate()}</span>
{#if isToday} {#if isToday}
<span class="sr-only" <span class="sr-only"
>{t(T.calendar_today_marker)}</span >{t(T.calendar_today_marker)}</span
@@ -1393,6 +1349,51 @@
</p> </p>
{/if} {/if}
</div> </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> </div>
{#if modalImage} {#if modalImage}
@@ -1436,10 +1437,6 @@
transparent 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 /* iOS Safari zeigt trotz list-style:none noch den Disclosure-Marker
(dunkler Punkt oben-links der Kachel) → explizit ausblenden. */ (dunkler Punkt oben-links der Kachel) → explizit ausblenden. */
.calendar-block summary { .calendar-block summary {
@@ -1448,59 +1445,27 @@
.calendar-block summary::-webkit-details-marker { .calendar-block summary::-webkit-details-marker {
display: none; display: none;
} }
.calendar-block .event-item { /* Event-Karten: eigenständige gerundete Karten mit Abstand statt
position: relative; 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; 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); 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 /* iOS/WebKit zeigt sonst den <li>-Disc-Marker (dunkler Punkt links der
Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */ Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */
.calendar-block .event-item::marker { .calendar-block .event-card::marker {
content: ""; content: "";
} }
.calendar-block .event-item + .event-item { .calendar-block .event-card:hover {
border-top: 1px solid var(--color-stein-200, #e0dcd5); border-color: var(--color-wald-300, #93d8ac);
} box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.06);
/* 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);
} }
/* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover. /* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover.
pointer-events:none damit kein mouseenter/leave Loop entsteht. */ pointer-events:none damit kein mouseenter/leave Loop entsteht. */
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from '@storybook/sveltekit'; import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarCompactBlock from './CalendarCompactBlock.svelte'; import CalendarCompactBlock from './CalendarCompactBlock.svelte';
// Übersetzungen laufen über die `translations`-Prop (nicht Context), s. CalendarBlock.
import translations from './_fixtures/translations.json';
const meta = { const meta = {
title: 'CMS-Blocks/CalendarCompact', title: 'CMS-Blocks/CalendarCompact',
@@ -37,5 +39,6 @@ export const Standard: Story = {
linkTo: { _slug: 'termine' }, linkTo: { _slug: 'termine' },
items, items,
}, },
translations,
}, },
}; };
@@ -12,6 +12,10 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Standard: Story = { 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: { args: {
block: { block: {
number: '08', number: '08',
@@ -23,6 +27,7 @@ export const Standard: Story = {
{ {
src: 'https://cms.pm86.de/api/assets/files/beispiel.pdf?_environment=windwiderstand', src: 'https://cms.pm86.de/api/assets/files/beispiel.pdf?_environment=windwiderstand',
title: 'Einwendung Vorlage', title: 'Einwendung Vorlage',
description: 'Zum Ausfüllen und Einreichen beim zuständigen Landratsamt',
mime: 'application/pdf', mime: 'application/pdf',
size: 248000, size: 248000,
filename: 'einwendung-vorlage.pdf', filename: 'einwendung-vorlage.pdf',
+16 -67
View File
@@ -3,7 +3,6 @@
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte"; import SectionHeader from "$lib/components/SectionHeader.svelte";
import Badge from "$lib/components/Badge.svelte";
import type { FilesBlockData } from "$lib/block-types"; import type { FilesBlockData } from "$lib/block-types";
import { import {
extractCmsFileField, extractCmsFileField,
@@ -26,66 +25,9 @@
label: string; label: string;
description?: string; description?: string;
sizeLabel: string | null; sizeLabel: string | null;
ext: string | null; extLabel: string;
iconName: 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( const resolved = $derived(
((block.items ?? []) as unknown[]) ((block.items ?? []) as unknown[])
.map((raw): Resolved | null => { .map((raw): Resolved | null => {
@@ -103,8 +45,7 @@
label, label,
description: field.description, description: field.description,
sizeLabel: formatFileSize(field.size), sizeLabel: formatFileSize(field.size),
ext, extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
iconName: iconForExt(ext),
}; };
}) })
.filter((r): r is Resolved => r !== null), .filter((r): r is Resolved => r !== null),
@@ -130,27 +71,35 @@
{#if resolved.length === 0} {#if resolved.length === 0}
<p class="text-sm text-stein-500">Keine Dateien verfügbar.</p> <p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
{:else} {: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} {#each resolved as f}
<li class="list-none! ml-0"> <li class="list-none! ml-0">
<a <a
href={f.href} 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" target="_blank"
rel="noopener noreferrer" 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-1 min-w-0 flex flex-col">
<span class="flex items-center gap-1.5 min-w-0"> <span class="flex items-baseline gap-1.5 min-w-0">
<span class="text-sm text-stein-900 group-hover:underline truncate">{f.label}</span> <span class="text-sm text-stein-900 truncate">{f.label}</span>
{#if f.sizeLabel} {#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} {/if}
</span> </span>
{#if f.description} {#if f.description}
<span class="text-xs text-stein-400">{f.description}</span> <span class="text-xs text-stein-400">{f.description}</span>
{/if} {/if}
</span> </span>
<Icon
icon="lucide:download"
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
aria-hidden="true"
/>
</a> </a>
</li> </li>
{/each} {/each}