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
+19 -4
View File
@@ -383,15 +383,30 @@ export interface LiveStrommixBlockData {
} }
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. /** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time.
* Lokale Erweiterungen (terminEnde, location, tags) sind im RustyCMS-Schema * Lokale Erweiterungen sind im RustyCMS-Schema ergänzt und werden bis zur
* ergänzt und werden bis zur OpenAPI-Regen hier inline mitgeführt. */ * OpenAPI-Regen hier inline mitgeführt. */
export type CalendarItemData = components["schemas"]["calendar_item"] & { export type CalendarItemData = components["schemas"]["calendar_item"] & {
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */ /** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
terminEnde?: string; terminEnde?: string;
/** Optionale Veranstaltungsort-Beschreibung (Adresse oder Name). */ /** Optionaler Veranstaltungsort (string oder {text, lat, lng}). */
location?: string; location?: string | { text?: string; lat?: number; lng?: number };
/** Optionale Tags zur Filterung. */ /** Optionale Tags zur Filterung. */
tags?: string[]; tags?: string[];
/** Optionales Veranstaltungsbild (aufgelöstes image-Feld oder Slug). */
image?:
| string
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
};
/** Optionaler Dateianhang (z.B. PDF-Flyer). */
attachment?: {
src?: string;
label?: string;
};
}; };
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */ /** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
src,
alt = "",
onclose,
}: { src: string; alt?: string; onclose: () => void } = $props();
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
onMount(() => window.addEventListener("keydown", onKeydown));
onDestroy(() => {
if (typeof window !== "undefined")
window.removeEventListener("keydown", onKeydown);
});
</script>
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 p-4 cursor-zoom-out"
onclick={onclose}
role="dialog"
aria-modal="true"
aria-label={alt || "Bild Vollansicht"}
>
<img
{src}
{alt}
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
onclick={(e) => e.stopPropagation()}
/>
<button
type="button"
onclick={onclose}
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" aria-hidden="true" />
</button>
</div>
+114 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy, tick } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types"; import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
@@ -11,8 +11,13 @@
mapsUrl, mapsUrl,
type CalendarItemICS, type CalendarItemICS,
} from "$lib/calendar-ics"; } from "$lib/calendar-ics";
import {
getCmsImageUrl,
extractCmsImageField,
} from "$lib/rusty-image";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
type EventCardItem = CalendarItemData & { type EventCardItem = CalendarItemData & {
start: Date; start: Date;
@@ -442,6 +447,18 @@
} }
let shareToastKey = $state<string | null>(null); 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( async function shareEvent(
ev: EventCardItem, ev: EventCardItem,
@@ -457,8 +474,7 @@
const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : ""; const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : "";
const parts = [`${titleStr}`, `${dateStr}${timeInfo}`]; const parts = [`${titleStr}`, `${dateStr}${timeInfo}`];
if (locStr) parts.push(locStr); if (locStr) parts.push(locStr);
const href = getEventLinkHref(ev.link); const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
const url = href || window.location.href;
const text = parts.join("\n"); const text = parts.join("\n");
const key = ev._slug ?? String(ev.start.getTime()); 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); let widgetEl: HTMLElement | null = $state(null);
onMount(() => { onMount(async () => {
feedHost = window.location.host; feedHost = window.location.host;
hasHover = window.matchMedia( hasHover = window.matchMedia(
"(hover: hover) and (pointer: fine)", "(hover: hover) and (pointer: fine)",
@@ -498,6 +524,15 @@
tickTimer = setInterval(() => { tickTimer = setInterval(() => {
nowMs = Date.now(); nowMs = Date.now();
}, 60_000); }, 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(() => { onDestroy(() => {
if (tickTimer) clearInterval(tickTimer); if (tickTimer) clearInterval(tickTimer);
@@ -527,10 +562,11 @@
item.isMultiDay item.isMultiDay
)} )}
<li <li
id={eventId(item)}
class="event-item {isNext ? 'event-item-next' : ''}" class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency} data-urgency={urgency}
> >
<details class="group" open={openByDefault}> <details class="group/event" 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="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> </div>
<Icon <Icon
icon="mdi:chevron-down" 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" aria-hidden="true"
/> />
</summary> </summary>
@@ -677,6 +713,31 @@
{/each} {/each}
</div> </div>
{/if} {/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"> <div class="flex flex-wrap gap-2 pt-1 text-xs">
{#if eventHref} {#if eventHref}
<a <a
@@ -698,76 +759,103 @@
href={mapsUrl(locText)} href={mapsUrl(locText)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-outline btn-sm" class="btn-outline btn-sm group"
aria-label={t(T.calendar_open_maps)}
> >
<Icon <Icon
icon="mdi:map-marker" icon="mdi:map-marker"
class="size-3.5 shrink-0" class="size-3.5 shrink-0"
aria-hidden="true" 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> </a>
{/if} {/if}
<button <button
type="button" type="button"
onclick={() => onclick={() =>
downloadICS(toICS(item), fileSlug(title))} 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
icon="mdi:download" icon="mdi:download"
class="size-3.5" class="size-3.5 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
{t(T.calendar_download_ics)} <span class="whitespace-nowrap">{t(T.calendar_download_ics)}</span>
</button> </button>
<a <a
href={googleCalUrl(toICS(item))} href={googleCalUrl(toICS(item))}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-outline btn-sm" class="btn-outline btn-sm group"
aria-label={t(T.calendar_add_to_google)}
> >
<Icon <Icon
icon="mdi:google" icon="mdi:google"
class="size-3.5" class="size-3.5 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
{t(T.calendar_add_to_google)} <span class="whitespace-nowrap">{t(T.calendar_add_to_google)}</span>
</a> </a>
{#if feedHost} {#if feedHost}
{@const shareKey = {@const shareKey =
item._slug ?? String(item.start.getTime())} 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} {@const shared = shareToastKey === shareKey}
<button <button
type="button" type="button"
onclick={() => onclick={() =>
shareEvent(item, title, locText)} shareEvent(item, title, locText)}
class="btn-outline btn-sm" class="btn-outline btn-sm group"
aria-label={t(T.calendar_share_aria)} aria-label={t(T.calendar_share_aria)}
> >
<Icon <Icon
icon={shared icon={shared
? "mdi:check" ? "mdi:check"
: "mdi:share-variant"} : "mdi:share-variant"}
class="size-3.5" class="size-3.5 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
{shared <span class="whitespace-nowrap">{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}</span>
? t(T.calendar_share_copied)
: t(T.calendar_share)}
</button> </button>
{/if} {/if}
<button <button
type="button" type="button"
onclick={() => jumpToEventInGrid(item)} onclick={() => jumpToEventInGrid(item)}
class="btn-outline btn-sm" class="btn-outline btn-sm group"
aria-label={t(T.calendar_jump_to_month)}
> >
<Icon <Icon
icon="mdi:calendar-search" icon="mdi:calendar-search"
class="size-3.5" class="size-3.5 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
{t(T.calendar_jump_to_month)} <span class="whitespace-nowrap">{t(T.calendar_jump_to_month)}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1136,6 +1224,10 @@
</div> </div>
</div> </div>
{#if modalImage}
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
{/if}
<style> <style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow. /* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute" Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
@@ -69,6 +69,12 @@
"map-marker-outline": { "map-marker-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>" "body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
}, },
"magnify-plus": {
"body": "<path fill=\"currentColor\" d=\"M9 2a7 7 0 0 1 7 7c0 1.57-.5 3-1.39 4.19l.8.81H16l6 6l-2 2l-6-6v-.59l-.81-.8A6.9 6.9 0 0 1 9 16a7 7 0 0 1-7-7a7 7 0 0 1 7-7M8 5v3H5v2h3v3h2v-3h3V8h-3V5z\"/>"
},
"file-download": {
"body": "<path fill=\"currentColor\" d=\"M14 2H6c-1.11 0-2 .89-2 2v16c0 1.11.89 2 2 2h12c1.11 0 2-.89 2-2V8zm-2 17l-4-4h2.5v-3h3v3H16zm1-10V3.5L18.5 9z\"/>"
},
"download": { "download": {
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>" "body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
}, },