feat(calendar): grid color polish, month header layout, direct event links
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 53s

- month label left + larger, prev/next arrows grouped right
- weekday headers colored (himmel/stein by weekday/weekend)
- day cells colored by weekday/weekend + event state
- event anchor IDs, onDestroy SSR fix, ImageModal component
- copy link button, action buttons always show text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-12 20:43:21 +02:00
parent 23faef550e
commit 7ea23531e9
+186 -114
View File
@@ -11,10 +11,7 @@
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import {
getCmsImageUrl,
extractCmsImageField,
} from "$lib/rusty-image";
import { getCmsImageUrl, extractCmsImageField } from "$lib/rusty-image";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
@@ -432,9 +429,7 @@
const webcalUrl = $derived(
feedHost ? `webcal://${feedHost}/api/kalender.ics` : "#",
);
const icsUrl = $derived(
feedHost ? `/api/kalender.ics` : "#",
);
const icsUrl = $derived(feedHost ? `/api/kalender.ics` : "#");
const isApplePlatform = $derived(
feedHost
? /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent)
@@ -566,7 +561,10 @@
class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency}
>
<details class="group/event" open={openByDefault || linkedSlug === eventId(item)}>
<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"
>
@@ -716,12 +714,28 @@
{#if item.image}
{@const imageField = extractCmsImageField(item.image)}
{#if imageField}
{@const thumbSrc = getCmsImageUrl(imageField.url, { width: 160, height: 160, fit: "cover" })}
{@const fullSrc = getCmsImageUrl(imageField.url, { width: 1400, fit: "contain" })}
{@const imageAlt = imageField.alt ?? (typeof item.image === "object" && "description" in item.image ? (item.image.description ?? "") : "")}
{@const thumbSrc = getCmsImageUrl(imageField.url, {
width: 160,
height: 160,
fit: "cover",
})}
{@const fullSrc = getCmsImageUrl(imageField.url, {
width: 1400,
fit: "contain",
})}
{@const imageAlt =
imageField.alt ??
(typeof item.image === "object" &&
"description" in item.image
? (item.image.description ?? "")
: "")}
<button
type="button"
onclick={() => (modalImage = { src: fullSrc, alt: imageAlt })}
onclick={() =>
(modalImage = {
src: fullSrc,
alt: imageAlt,
})}
class="group relative w-24 h-24 rounded overflow-hidden border border-stein-200 shrink-0 hover:opacity-90 transition-opacity"
aria-label="Bild vergrößern"
>
@@ -731,8 +745,14 @@
class="w-full h-full object-cover"
loading="lazy"
/>
<span class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors">
<Icon icon="mdi:magnify-plus" class="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" aria-hidden="true" />
<span
class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors"
>
<Icon
icon="mdi:magnify-plus"
class="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow"
aria-hidden="true"
/>
</span>
</button>
{/if}
@@ -767,7 +787,9 @@
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
<span class="whitespace-nowrap"
>{t(T.calendar_open_maps)}</span
>
</a>
{/if}
{#if item.attachment?.src}
@@ -775,10 +797,18 @@
<a
href={dlSrc}
class="btn-outline btn-sm group"
aria-label={item.attachment.label || "Anhang herunterladen"}
aria-label={item.attachment.label ||
"Anhang herunterladen"}
>
<Icon icon="mdi:file-download" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="whitespace-nowrap">{item.attachment.label || "Anhang herunterladen"}</span>
<Icon
icon="mdi:file-download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{item.attachment.label ||
"Anhang herunterladen"}</span
>
</a>
{/if}
<button
@@ -793,7 +823,9 @@
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_download_ics)}</span>
<span class="whitespace-nowrap"
>{t(T.calendar_download_ics)}</span
>
</button>
<a
href={googleCalUrl(toICS(item))}
@@ -807,7 +839,9 @@
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_add_to_google)}</span>
<span class="whitespace-nowrap"
>{t(T.calendar_add_to_google)}</span
>
</a>
{#if feedHost}
{@const shareKey =
@@ -820,17 +854,22 @@
aria-label={t(T.post_action_copy)}
>
<Icon
icon={copied ? "mdi:check" : "mdi:link-variant"}
icon={copied
? "mdi:check"
: "mdi:link-variant"}
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{copied ? t(T.post_action_copied) : t(T.post_action_copy)}</span>
<span class="whitespace-nowrap"
>{copied
? t(T.post_action_copied)
: t(T.post_action_copy)}</span
>
</button>
{@const shared = shareToastKey === shareKey}
<button
type="button"
onclick={() =>
shareEvent(item, title, locText)}
onclick={() => shareEvent(item, title, locText)}
class="btn-outline btn-sm group"
aria-label={t(T.calendar_share_aria)}
>
@@ -841,7 +880,11 @@
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}</span>
<span class="whitespace-nowrap"
>{shared
? t(T.calendar_share_copied)
: t(T.calendar_share)}</span
>
</button>
{/if}
<button
@@ -855,7 +898,9 @@
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_jump_to_month)}</span>
<span class="whitespace-nowrap"
>{t(T.calendar_jump_to_month)}</span
>
</button>
</div>
</div>
@@ -908,52 +953,52 @@
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"
>
{#if isApplePlatform}
<a
href={webcalUrl}
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{:else}
<a
href={icsUrl}
download="windwiderstand-termine.ics"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{/if}
{#if items.length > 0}
<button
type="button"
onclick={exportAll}
class="btn-outline btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
{/if}
</div>
<div
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-stein-200 bg-himmel-100"
>
{#if isApplePlatform}
<a
href={webcalUrl}
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{:else}
<a
href={icsUrl}
download="windwiderstand-termine.ics"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{/if}
{#if items.length > 0}
<button
type="button"
onclick={exportAll}
class="btn-outline btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
{/if}
</div>
{/if}
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
@@ -964,41 +1009,45 @@
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
>
<div class="w-full">
<div class="flex items-center justify-between gap-2 mb-4">
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon
icon="mdi:chevron-left"
class="size-5"
aria-hidden="true"
/>
</button>
<div class="flex items-center justify-between gap-2 mb-2">
<span
class="text-sm font-medium text-himmel-900 capitalize whitespace-nowrap"
class="text-xl font-semibold text-himmel-900 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon
icon="mdi:chevron-right"
class="size-5"
aria-hidden="true"
/>
</button>
<div class="flex items-center gap-0.5">
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon
icon="mdi:chevron-left"
class="size-5"
aria-hidden="true"
/>
</button>
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon
icon="mdi:chevron-right"
class="size-5"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
{#each weekdays as w}
{#each weekdays as w, i}
<div
class="py-1.5 text-xs font-semibold text-himmel-800"
class="py-1.5 text-xs font-bold rounded-xs {i >= 5
? 'bg-stein-100/80 text-stein-600'
: 'bg-himmel-200/50 text-himmel-800'}"
>
{w}
</div>
@@ -1014,14 +1063,22 @@
{@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 {isCurrentMonth
? 'text-himmel-900 hover:bg-himmel-200'
: 'text-himmel-700 opacity-60 hover:opacity-80'} bg-himmel-200/70 font-semibold hover:bg-himmel-300 {isSelected
? 'ring-2 ring-himmel-700 bg-himmel-300'
: ''} {isToday ? 'today-cell' : ''}"
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer font-semibold
{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'
: ''}
{isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)}
onmouseenter={(e) => showTooltip(e, key)}
onmouseleave={hideTooltip}
@@ -1049,11 +1106,14 @@
</button>
{:else}
<div
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default bg-himmel-50/40 {isCurrentMonth
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default
{isWeekend
? 'bg-stein-100/60'
: 'bg-himmel-50/50'}
{isCurrentMonth
? 'text-himmel-800'
: 'text-himmel-600 opacity-50'} {isToday
? 'today-cell'
: ''}"
: 'text-himmel-600 opacity-50'}
{isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined}
>
<span class="text-xs">{d.getDate()}</span>
@@ -1080,19 +1140,23 @@
>
{#each tooltipEvents.slice(0, 3) as ev}
<div class="cal-tooltip-row">
<span class="cal-tooltip-title">{ev.title || t(T.calendar_untitled)}</span>
<span class="cal-tooltip-title"
>{ev.title || t(T.calendar_untitled)}</span
>
{#if ev.timeStr}
<span class="cal-tooltip-time">{ev.timeStr}</span>
{/if}
</div>
{/each}
{#if tooltipEvents.length > 3}
<div class="cal-tooltip-more">+{tooltipEvents.length - 3} weitere</div>
<div class="cal-tooltip-more">
+{tooltipEvents.length - 3} weitere
</div>
{/if}
</div>
{/if}
<!-- Events: voll Breite, unten. Akkordeon-Liste, in natürlichem
<!-- Events: voll Breite, unten. Akkordeon-Liste, in natürlichem
Page-Flow (kein internes Scrollen). Kalender-Klick filtert auf
einen Tag; ohne Filter Liste ab heute, gruppiert nach Tag. -->
<div class="calendar-events-panel bg-stein-0 border-t border-stein-200">
@@ -1225,7 +1289,11 @@
</div>
{#if modalImage}
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
<ImageModal
src={modalImage.src}
alt={modalImage.alt}
onclose={() => (modalImage = null)}
/>
{/if}
<style>
@@ -1235,7 +1303,11 @@
.calendar-block .today-cell {
outline: 2px solid var(--color-wald-400, #84e0a4);
outline-offset: -2px;
background-color: color-mix(in srgb, var(--color-wald-200, #bbf0cf) 40%, transparent);
background-color: color-mix(
in srgb,
var(--color-wald-200, #bbf0cf) 40%,
transparent
);
}
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator