feat(calendar): image/attachment per event, direct links, copy link, ImageModal component
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 53s

- calendar_item schema: flyer → image (type: image), attachment (assetUrl)
- CalendarItemData: image + attachment fields
- event anchor IDs (#event-{slug}) with onMount hash scroll + auto-open
- share button uses direct calendar anchor URL
- copy link button (mdi:link-variant) copies anchor URL to clipboard
- ImageModal extracted as reusable component
- action buttons always show text (no expand-on-hover)
- onDestroy window guard (SSR fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-12 20:02:30 +02:00
parent 6fc2c0a345
commit 23faef550e
4 changed files with 183 additions and 26 deletions
+114 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, tick } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
@@ -11,8 +11,13 @@
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import {
getCmsImageUrl,
extractCmsImageField,
} from "$lib/rusty-image";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
type EventCardItem = CalendarItemData & {
start: Date;
@@ -442,6 +447,18 @@
}
let shareToastKey = $state<string | null>(null);
let copyToastKey = $state<string | null>(null);
let modalImage = $state<{ src: string; alt: string } | null>(null);
let linkedSlug = $state<string | null>(null);
function eventId(item: EventCardItem): string {
if (item._slug) return `event-${item._slug}`;
const titlePart = (item.title || "event")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return `event-${titlePart}-${dayKey(item.start)}`;
}
async function shareEvent(
ev: EventCardItem,
@@ -457,8 +474,7 @@
const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : "";
const parts = [`${titleStr}`, `${dateStr}${timeInfo}`];
if (locStr) parts.push(locStr);
const href = getEventLinkHref(ev.link);
const url = href || window.location.href;
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
const text = parts.join("\n");
const key = ev._slug ?? String(ev.start.getTime());
@@ -488,9 +504,19 @@
}
}
async function copyEventLink(ev: EventCardItem): Promise<void> {
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
const key = ev._slug ?? String(ev.start.getTime());
await navigator.clipboard.writeText(url).catch(() => {});
copyToastKey = key;
setTimeout(() => {
if (copyToastKey === key) copyToastKey = null;
}, 2000);
}
let widgetEl: HTMLElement | null = $state(null);
onMount(() => {
onMount(async () => {
feedHost = window.location.host;
hasHover = window.matchMedia(
"(hover: hover) and (pointer: fine)",
@@ -498,6 +524,15 @@
tickTimer = setInterval(() => {
nowMs = Date.now();
}, 60_000);
const hash = window.location.hash.slice(1);
if (hash.startsWith("event-")) {
linkedSlug = hash;
await tick();
document
.getElementById(hash)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
onDestroy(() => {
if (tickTimer) clearInterval(tickTimer);
@@ -527,10 +562,11 @@
item.isMultiDay
)}
<li
id={eventId(item)}
class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency}
>
<details class="group" open={openByDefault}>
<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"
>
@@ -647,7 +683,7 @@
</div>
<Icon
icon="mdi:chevron-down"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open:rotate-180"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open/event:rotate-180"
aria-hidden="true"
/>
</summary>
@@ -677,6 +713,31 @@
{/each}
</div>
{/if}
{#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 ?? "") : "")}
<button
type="button"
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"
>
<img
src={thumbSrc}
alt={imageAlt}
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>
</button>
{/if}
{/if}
<div class="flex flex-wrap gap-2 pt-1 text-xs">
{#if eventHref}
<a
@@ -698,76 +759,103 @@
href={mapsUrl(locText)}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_open_maps)}
>
<Icon
icon="mdi:map-marker"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_open_maps)}
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
</a>
{/if}
{#if item.attachment?.src}
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
<a
href={dlSrc}
class="btn-outline btn-sm group"
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>
</a>
{/if}
<button
type="button"
onclick={() =>
downloadICS(toICS(item), fileSlug(title))}
class="btn-outline btn-sm"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_download_ics)}
>
<Icon
icon="mdi:download"
class="size-3.5"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_download_ics)}
<span class="whitespace-nowrap">{t(T.calendar_download_ics)}</span>
</button>
<a
href={googleCalUrl(toICS(item))}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_add_to_google)}
>
<Icon
icon="mdi:google"
class="size-3.5"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_add_to_google)}
<span class="whitespace-nowrap">{t(T.calendar_add_to_google)}</span>
</a>
{#if feedHost}
{@const shareKey =
item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
<button
type="button"
onclick={() => copyEventLink(item)}
class="btn-outline btn-sm group"
aria-label={t(T.post_action_copy)}
>
<Icon
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>
</button>
{@const shared = shareToastKey === shareKey}
<button
type="button"
onclick={() =>
shareEvent(item, title, locText)}
class="btn-outline btn-sm"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_share_aria)}
>
<Icon
icon={shared
? "mdi:check"
: "mdi:share-variant"}
class="size-3.5"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{shared
? t(T.calendar_share_copied)
: t(T.calendar_share)}
<span class="whitespace-nowrap">{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}</span>
</button>
{/if}
<button
type="button"
onclick={() => jumpToEventInGrid(item)}
class="btn-outline btn-sm"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_jump_to_month)}
>
<Icon
icon="mdi:calendar-search"
class="size-3.5"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_jump_to_month)}
<span class="whitespace-nowrap">{t(T.calendar_jump_to_month)}</span>
</button>
</div>
</div>
@@ -1136,6 +1224,10 @@
</div>
</div>
{#if modalImage}
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
{/if}
<style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"