feat(calendar): image/attachment per event, direct links, copy link, ImageModal component
- 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:
+19
-4
@@ -383,15 +383,30 @@ export interface LiveStrommixBlockData {
|
||||
}
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time.
|
||||
* Lokale Erweiterungen (terminEnde, location, tags) sind im RustyCMS-Schema
|
||||
* ergänzt und werden bis zur OpenAPI-Regen hier inline mitgeführt. */
|
||||
* Lokale Erweiterungen sind im RustyCMS-Schema ergänzt und werden bis zur
|
||||
* OpenAPI-Regen hier inline mitgeführt. */
|
||||
export type CalendarItemData = components["schemas"]["calendar_item"] & {
|
||||
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
|
||||
terminEnde?: string;
|
||||
/** Optionale Veranstaltungsort-Beschreibung (Adresse oder Name). */
|
||||
location?: string;
|
||||
/** Optionaler Veranstaltungsort (string oder {text, lat, lng}). */
|
||||
location?: string | { text?: string; lat?: number; lng?: number };
|
||||
/** Optionale Tags zur Filterung. */
|
||||
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). */
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
"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\"/>"
|
||||
},
|
||||
"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": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user