feat(termin): vollwertige Detailseite + QR/Detail-Link im Kalender
/termin/<slug> ist jetzt echte Seite statt Redirect: - alle Infos ungekürzt (Datum/Zeit in Europe/Berlin, Ort+Maps, Tags, Bild+Lightbox, volle Beschreibung, Anhang, externer Link) - alle Aktionen: ICS, Google, Link kopieren, Teilen, QR, Social-Bild - Link zum Kalender drunter (kein Auto-Redirect mehr) - Breite via Layout-.container-custom (gleich wie andere Seiten) - SEO: OG/Twitter/canonical zentral aus Layout (kein Doppel-Tag), Event-JSON-LD + Breadcrumb für Rich Results / Share-Preview CalendarBlock: QR-Button + "Details →"-Link je Termin (→ /termin/<slug>/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
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";
|
||||||
|
|
||||||
type EventCardItem = CalendarItemData & {
|
type EventCardItem = CalendarItemData & {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -458,6 +459,18 @@
|
|||||||
socialModal = null;
|
socialModal = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR-Code zum direkten Ausgeben/Scannen (Aushang, Infostand). Nutzt die
|
||||||
|
// canonical /termin/<slug>-URL (hat OG-Tags + Redirect), Fallback = Hash-
|
||||||
|
// Anchor im Kalender wenn kein Slug.
|
||||||
|
let qrModal = $state<{ value: string; label: string } | null>(null);
|
||||||
|
|
||||||
|
function openQr(item: EventCardItem) {
|
||||||
|
const url = item._slug
|
||||||
|
? `${window.location.origin}/termin/${encodeURIComponent(item._slug)}/`
|
||||||
|
: `${window.location.origin}${window.location.pathname}#${eventId(item)}`;
|
||||||
|
qrModal = { value: url, label: item.title || "Termin" };
|
||||||
|
}
|
||||||
|
|
||||||
function eventId(item: EventCardItem): string {
|
function eventId(item: EventCardItem): string {
|
||||||
if (item._slug) return `event-${item._slug}`;
|
if (item._slug) return `event-${item._slug}`;
|
||||||
const titlePart = (item.title || "event")
|
const titlePart = (item.title || "event")
|
||||||
@@ -781,8 +794,21 @@
|
|||||||
|
|
||||||
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
|
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
|
||||||
<div class="pt-2 space-y-2">
|
<div class="pt-2 space-y-2">
|
||||||
{#if eventHref || locText || item.attachment?.src}
|
{#if item._slug || eventHref || locText || item.attachment?.src}
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#if item._slug}
|
||||||
|
<a
|
||||||
|
href={`/termin/${encodeURIComponent(item._slug)}/`}
|
||||||
|
class="btn-tertiary btn-sm"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
<Icon
|
||||||
|
icon="mdi:arrow-right"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
{#if eventHref}
|
{#if eventHref}
|
||||||
<a
|
<a
|
||||||
href={eventHref}
|
href={eventHref}
|
||||||
@@ -902,6 +928,16 @@
|
|||||||
Bild
|
Bild
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => openQr(item)}
|
||||||
|
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
|
||||||
|
title="QR-Code anzeigen & herunterladen"
|
||||||
|
aria-label="QR-Code anzeigen & herunterladen"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:qrcode" class="size-3.5" />
|
||||||
|
QR
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1325,6 +1361,15 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if qrModal}
|
||||||
|
<QrModal
|
||||||
|
value={qrModal.value}
|
||||||
|
label={qrModal.label}
|
||||||
|
sublabel="Termin-Link"
|
||||||
|
onclose={() => (qrModal = 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"
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { marked } from "$lib/markdown-safe";
|
|||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEO-Detail-Route pro Termin. Hat eigene OG-Tags (für Link-Previews bei
|
* Eigenständige Detail-Seite pro Termin: alle Infos + Aktionen ungekürzt,
|
||||||
* Facebook/WhatsApp/Twitter), leitet sonst weiter an /kalender/#event-<slug>
|
* eigene OG-Tags (Link-Previews FB/WhatsApp/Twitter) und ein Link zum
|
||||||
* (das war die ursprüngliche Hash-basierte Ziel-URL und bleibt damit
|
* Kalender (KEIN Redirect mehr — die Seite ist das Ziel des QR-/Direktlinks).
|
||||||
* funktional).
|
|
||||||
*
|
*
|
||||||
* Crawler bekommen die Meta-Tags zu sehen, normale Browser werden via
|
* terminZeit ist mit Offset gespeichert (z. B. 10:00+02:00). Labels müssen in
|
||||||
* Client-Side-Redirect (im +page.svelte) sofort zum Kalender geschickt.
|
* Europe/Berlin gerendert werden, sonst zeigt der SSR-Prozess (Server-TZ=UTC)
|
||||||
|
* 2h zu früh.
|
||||||
*/
|
*/
|
||||||
|
const TZ = "Europe/Berlin";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||||
const slug = params.slug;
|
const slug = params.slug;
|
||||||
const rawItem = await getCalendarItemBySlug(slug, { locale: "de" });
|
const rawItem = await getCalendarItemBySlug(slug, { locale: "de" });
|
||||||
@@ -20,42 +22,78 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
|||||||
const item = rawItem as typeof rawItem & {
|
const item = rawItem as typeof rawItem & {
|
||||||
location?: unknown;
|
location?: unknown;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
terminEnde?: string;
|
||||||
|
tags?: string[];
|
||||||
|
image?: unknown;
|
||||||
|
attachment?: { src?: string; label?: string };
|
||||||
|
link?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const siteBaseUrl = (env.PUBLIC_SITE_URL || "").replace(/\/$/, "");
|
const siteBaseUrl = (env.PUBLIC_SITE_URL || "").replace(/\/$/, "");
|
||||||
const title = (item.title ?? "Termin").trim();
|
const title = (item.title ?? "Termin").trim();
|
||||||
const terminZeit = item.terminZeit;
|
const terminZeit = item.terminZeit;
|
||||||
|
const terminEnde = item.terminEnde ?? null;
|
||||||
|
const hasTime = !!terminZeit && !/T00:00:00/.test(terminZeit);
|
||||||
|
|
||||||
|
const weekdayLabel = terminZeit
|
||||||
|
? new Date(terminZeit).toLocaleDateString("de-DE", { weekday: "long", timeZone: TZ })
|
||||||
|
: "";
|
||||||
const dateLabel = terminZeit
|
const dateLabel = terminZeit
|
||||||
? new Date(terminZeit).toLocaleDateString("de-DE", {
|
? new Date(terminZeit).toLocaleDateString("de-DE", {
|
||||||
weekday: "long",
|
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
timeZone: TZ,
|
||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
const timeLabel =
|
const timeLabel = hasTime
|
||||||
terminZeit && !/T00:00:00/.test(terminZeit)
|
? new Date(terminZeit).toLocaleTimeString("de-DE", {
|
||||||
? new Date(terminZeit).toLocaleTimeString("de-DE", {
|
hour: "2-digit",
|
||||||
hour: "2-digit",
|
minute: "2-digit",
|
||||||
minute: "2-digit",
|
timeZone: TZ,
|
||||||
}) + " Uhr"
|
}) + " Uhr"
|
||||||
|
: "";
|
||||||
|
const endDateLabel =
|
||||||
|
terminEnde && !/T00:00:00/.test(terminEnde)
|
||||||
|
? new Date(terminEnde).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: TZ,
|
||||||
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const locationText =
|
const locObj =
|
||||||
typeof item.location === "object" && item.location !== null
|
typeof item.location === "object" && item.location !== null
|
||||||
? (((item.location as { text?: string }).text ?? "").trim() || null)
|
? (item.location as { text?: string; lat?: number; lng?: number })
|
||||||
: null;
|
: null;
|
||||||
|
const locationText = locObj
|
||||||
|
? (locObj.text ?? "").trim() || null
|
||||||
|
: typeof item.location === "string"
|
||||||
|
? item.location.trim() || null
|
||||||
|
: null;
|
||||||
|
const hasCoords =
|
||||||
|
!!locObj && typeof locObj.lat === "number" && typeof locObj.lng === "number";
|
||||||
|
|
||||||
// Plain-Text-Snippet aus Description (Markdown → Text, ~200 Zeichen)
|
// Externer Link / Post-Link
|
||||||
let descriptionPlain = "";
|
let externalLink: string | null = null;
|
||||||
if (item.description) {
|
const link = item.link as { url?: string; _slug?: string } | string | undefined;
|
||||||
const html = marked.parse(item.description) as string;
|
if (typeof link === "string" && link) externalLink = link;
|
||||||
descriptionPlain = html
|
else if (link && typeof link === "object") {
|
||||||
.replace(/<[^>]+>/g, " ")
|
if (link.url) externalLink = link.url;
|
||||||
.replace(/\s+/g, " ")
|
else if (link._slug) externalLink = `/post/${encodeURIComponent(link._slug)}`;
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const descriptionHtml = item.description
|
||||||
|
? (marked.parse(item.description) as string)
|
||||||
|
: "";
|
||||||
|
const descriptionPlain = descriptionHtml
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const tags = Array.isArray(item.tags) ? item.tags.filter((t) => typeof t === "string") : [];
|
||||||
|
|
||||||
const seoDescription = [
|
const seoDescription = [
|
||||||
dateLabel + (timeLabel ? `, ${timeLabel}` : ""),
|
dateLabel + (timeLabel ? `, ${timeLabel}` : ""),
|
||||||
locationText,
|
locationText,
|
||||||
@@ -66,8 +104,54 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
|||||||
|
|
||||||
const socialImage = `${siteBaseUrl}/api/social/calendar/${encodeURIComponent(slug)}?format=og`;
|
const socialImage = `${siteBaseUrl}/api/social/calendar/${encodeURIComponent(slug)}?format=og`;
|
||||||
const calendarUrl = `/kalender/#event-${slug}`;
|
const calendarUrl = `/kalender/#event-${slug}`;
|
||||||
|
const canonical = `${siteBaseUrl}/termin/${encodeURIComponent(slug)}/`;
|
||||||
|
|
||||||
|
// schema.org Event — Rich-Result/SEO für Termine (Datum, Ort, Veranstalter).
|
||||||
|
const jsonLd = terminZeit
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Event",
|
||||||
|
name: title,
|
||||||
|
startDate: terminZeit,
|
||||||
|
...(terminEnde ? { endDate: terminEnde } : {}),
|
||||||
|
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||||
|
eventStatus: "https://schema.org/EventScheduled",
|
||||||
|
...(siteBaseUrl ? { url: canonical } : {}),
|
||||||
|
inLanguage: "de-DE",
|
||||||
|
...(descriptionPlain ? { description: descriptionPlain.slice(0, 500) } : {}),
|
||||||
|
image: socialImage,
|
||||||
|
organizer: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Windwiderstand Thüringen",
|
||||||
|
...(siteBaseUrl ? { url: siteBaseUrl } : {}),
|
||||||
|
},
|
||||||
|
...(locationText
|
||||||
|
? {
|
||||||
|
location: {
|
||||||
|
"@type": "Place",
|
||||||
|
name: locationText,
|
||||||
|
...(hasCoords
|
||||||
|
? {
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: locObj!.lat,
|
||||||
|
longitude: locObj!.lng,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: "Kalender", href: "/kalender/" },
|
||||||
|
{ label: title },
|
||||||
|
];
|
||||||
|
|
||||||
// CDN/Browser dürfen die SEO-Antwort cachen, da Crawler sie selten neu holen.
|
|
||||||
setHeaders({
|
setHeaders({
|
||||||
"cache-control": "public, s-maxage=300, stale-while-revalidate=3600",
|
"cache-control": "public, s-maxage=300, stale-while-revalidate=3600",
|
||||||
});
|
});
|
||||||
@@ -77,14 +161,28 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
|||||||
seoDescription,
|
seoDescription,
|
||||||
socialImage,
|
socialImage,
|
||||||
ogType: "article" as const,
|
ogType: "article" as const,
|
||||||
|
jsonLd,
|
||||||
|
breadcrumbItems,
|
||||||
calendarUrl,
|
calendarUrl,
|
||||||
item: {
|
item: {
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
|
terminZeit: terminZeit ?? null,
|
||||||
|
terminEnde,
|
||||||
|
hasTime,
|
||||||
|
weekdayLabel,
|
||||||
dateLabel,
|
dateLabel,
|
||||||
timeLabel,
|
timeLabel,
|
||||||
|
endDateLabel,
|
||||||
locationText,
|
locationText,
|
||||||
descriptionPlain: descriptionPlain.slice(0, 400),
|
descriptionHtml,
|
||||||
|
tags,
|
||||||
|
image: item.image ?? null,
|
||||||
|
attachment:
|
||||||
|
item.attachment && item.attachment.src
|
||||||
|
? { src: item.attachment.src, label: item.attachment.label ?? "Anhang" }
|
||||||
|
: null,
|
||||||
|
externalLink,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,256 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import "$lib/iconify-offline";
|
||||||
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
import {
|
||||||
|
downloadICS,
|
||||||
|
googleCalUrl,
|
||||||
|
mapsUrl,
|
||||||
|
type CalendarItemICS,
|
||||||
|
} from "$lib/calendar-ics";
|
||||||
|
import { getCmsImageUrl, extractCmsImageField } from "$lib/rusty-image";
|
||||||
|
import QrModal from "$lib/components/QrModal.svelte";
|
||||||
|
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||||
|
import ImageModal from "$lib/components/ImageModal.svelte";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
const t = useTranslate();
|
||||||
|
const item = $derived(data.item);
|
||||||
|
|
||||||
onMount(() => {
|
// Kanonische URL dieser Seite (für ICS-Link, Teilen, QR).
|
||||||
// JS-Redirect zum Kalender-Hash-Anchor (für normale Nutzer).
|
let pageUrl = $state("");
|
||||||
// Crawler ignorieren das und nutzen die OG-Tags aus dem Server-HTML.
|
$effect(() => {
|
||||||
window.location.replace(data.calendarUrl);
|
pageUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toICS(): CalendarItemICS {
|
||||||
|
const start = item.terminZeit ? new Date(item.terminZeit) : new Date();
|
||||||
|
const descPlain = item.descriptionHtml
|
||||||
|
? item.descriptionHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
title: item.title,
|
||||||
|
start,
|
||||||
|
end: item.terminEnde ? new Date(item.terminEnde) : null,
|
||||||
|
description: descPlain || undefined,
|
||||||
|
location: item.locationText || undefined,
|
||||||
|
url: item.externalLink || pageUrl || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSlug(s: string): string {
|
||||||
|
return (
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "termin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let qrOpen = $state(false);
|
||||||
|
let socialOpen = $state(false);
|
||||||
|
let modalImage = $state<{ src: string; alt: string } | null>(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
let shared = $state(false);
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
await navigator.clipboard.writeText(pageUrl).catch(() => {});
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
const text = [
|
||||||
|
item.title,
|
||||||
|
`${item.weekdayLabel}, ${item.dateLabel}${item.timeLabel ? ` · ${item.timeLabel}` : ""}`,
|
||||||
|
item.locationText,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
if (typeof navigator.share === "function") {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: item.title, text, url: pageUrl });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as DOMException).name === "AbortError") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(`${text}\n${pageUrl}`).catch(() => {});
|
||||||
|
shared = true;
|
||||||
|
setTimeout(() => (shared = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event-Bild auflösen
|
||||||
|
const imageField = $derived(item.image ? extractCmsImageField(item.image) : null);
|
||||||
|
const thumbSrc = $derived(
|
||||||
|
imageField ? getCmsImageUrl(imageField.url, { width: 900, fit: "contain" }) : null,
|
||||||
|
);
|
||||||
|
const fullSrc = $derived(
|
||||||
|
imageField ? getCmsImageUrl(imageField.url, { width: 1600, fit: "contain" }) : null,
|
||||||
|
);
|
||||||
|
const imageAlt = $derived(imageField?.alt ?? item.title);
|
||||||
|
|
||||||
|
const descHtml = $derived(item.descriptionHtml || "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<!-- SEO/OG/Twitter/canonical + Event-JSON-LD + Breadcrumb kommen zentral aus
|
||||||
<!-- Fallback-Redirect für Browser ohne JS (~0.5s Delay) -->
|
+layout.svelte (liest seoTitle/seoDescription/socialImage/ogType/jsonLd/
|
||||||
<meta http-equiv="refresh" content="1; url={data.calendarUrl}" />
|
breadcrumbItems aus $page.data). Hier KEINE eigenen Head-Tags → sonst
|
||||||
<!-- Robots-Hinweis: Crawler sollen die Hash-URL als Canonical sehen, aber
|
Doppel-Tags, die Scraper (WhatsApp/FB) verwirren. -->
|
||||||
diese Detail-Route bleibt indexierbar, damit Social-Previews greifen. -->
|
<!-- Breite kommt vom Layout-Wrapper (.container-custom in +layout.svelte) —
|
||||||
</svelte:head>
|
hier nur vertikaler Abstand, KEIN eigenes max-w/mx-auto/px (sonst schmaler
|
||||||
|
+ doppeltes Padding ggü. den anderen Seiten). -->
|
||||||
|
<article class="pt-10 pb-4">
|
||||||
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-wald-700">Termin</p>
|
||||||
|
<h1 class="mb-4 text-3xl font-bold leading-tight text-stein-900">{item.title}</h1>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-12 text-center">
|
<!-- Eckdaten -->
|
||||||
<p class="mb-2 text-xs uppercase tracking-wider text-stein-500">Termin</p>
|
<div class="space-y-1.5 text-stein-700">
|
||||||
<h1 class="mb-4 text-2xl font-bold text-stein-900">{data.item.title}</h1>
|
{#if item.dateLabel}
|
||||||
{#if data.item.dateLabel}
|
<p class="flex items-center gap-2">
|
||||||
<p class="text-stein-700">
|
<Icon icon="mdi:calendar" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
|
||||||
{data.item.dateLabel}{data.item.timeLabel ? `, ${data.item.timeLabel}` : ""}
|
<span class="font-medium">
|
||||||
</p>
|
{item.weekdayLabel ? `${item.weekdayLabel}, ` : ""}{item.dateLabel}{item.endDateLabel
|
||||||
|
? ` – ${item.endDateLabel}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if item.timeLabel}
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:clock-outline" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
|
||||||
|
<span class="tabular-nums">{item.timeLabel}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if item.locationText}
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:map-marker" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
|
||||||
|
<a
|
||||||
|
href={mapsUrl(item.locationText)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-stein-700 underline underline-offset-2 hover:text-wald-700"
|
||||||
|
>
|
||||||
|
{item.locationText}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if item.tags.length > 0}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||||
|
{#each item.tags as tg}
|
||||||
|
<span class="rounded-xs bg-himmel-100 px-2 py-0.5 text-xs text-himmel-800">{tg}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.item.locationText}
|
|
||||||
<p class="mt-1 text-stein-500">📍 {data.item.locationText}</p>
|
{#if thumbSrc && fullSrc}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (modalImage = { src: fullSrc, alt: imageAlt })}
|
||||||
|
class="group relative mt-6 block w-full overflow-hidden rounded-md"
|
||||||
|
aria-label="Bild vergrößern"
|
||||||
|
>
|
||||||
|
<img src={thumbSrc} alt={imageAlt} class="w-full object-contain" loading="lazy" />
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.item.descriptionPlain}
|
|
||||||
<p class="mt-4 text-sm text-stein-600">{data.item.descriptionPlain}</p>
|
{#if descHtml}
|
||||||
|
<div class="prose prose-stein mt-6 max-w-none font-light leading-relaxed">
|
||||||
|
{@html descHtml}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mt-8 text-xs text-stein-400">Du wirst gerade zum Kalender weitergeleitet…</p>
|
|
||||||
<a
|
{#if item.attachment}
|
||||||
href={data.calendarUrl}
|
<a
|
||||||
class="mt-4 inline-block rounded-md bg-wald-700 px-4 py-2 text-sm font-medium text-white no-underline hover:bg-wald-800"
|
href={`/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
|
||||||
>
|
class="btn-outline btn-sm mt-6 inline-flex group"
|
||||||
Im Kalender anzeigen
|
>
|
||||||
</a>
|
<Icon icon="mdi:file-download" class="size-4 shrink-0" aria-hidden="true" />
|
||||||
</div>
|
<span>{item.attachment.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.externalLink}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href={item.externalLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-tertiary btn-sm inline-flex"
|
||||||
|
>
|
||||||
|
{t(T.calendar_more_info)}
|
||||||
|
<Icon icon="mdi:open-in-new" class="size-3.5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Aktionen ungekürzt -->
|
||||||
|
<div class="mt-8 flex flex-wrap items-center gap-2 rounded-md border border-stein-200 bg-stein-50 p-2">
|
||||||
|
<button type="button" onclick={() => downloadICS(toICS(), fileSlug(item.title))} class="action-btn">
|
||||||
|
<Icon icon="mdi:download" class="size-4" /> ICS
|
||||||
|
</button>
|
||||||
|
<a href={googleCalUrl(toICS())} target="_blank" rel="noopener noreferrer" class="action-btn no-underline">
|
||||||
|
<Icon icon="mdi:google" class="size-4" /> Google
|
||||||
|
</a>
|
||||||
|
<span class="mx-1 h-5 w-px bg-stein-300" aria-hidden="true"></span>
|
||||||
|
<button type="button" onclick={copyLink} class="action-btn">
|
||||||
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-4" />
|
||||||
|
{copied ? t(T.post_action_copied) : "Link"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={share} class="action-btn">
|
||||||
|
<Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-4" />
|
||||||
|
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={() => (qrOpen = true)} class="action-btn">
|
||||||
|
<Icon icon="mdi:qrcode" class="size-4" /> QR
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={() => (socialOpen = true)} class="action-btn">
|
||||||
|
<Icon icon="mdi:image-outline" class="size-4" /> Bild
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link zum Kalender (kein Redirect) -->
|
||||||
|
<div class="mt-10 border-t border-stein-200 pt-6">
|
||||||
|
<a
|
||||||
|
href={data.calendarUrl}
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm font-medium text-wald-700 underline underline-offset-2 hover:text-wald-800"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:calendar-month" class="size-4" aria-hidden="true" />
|
||||||
|
Alle Termine im Kalender
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{#if modalImage}
|
||||||
|
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if qrOpen}
|
||||||
|
<QrModal value={pageUrl} label={item.title} sublabel="Termin-Link" onclose={() => (qrOpen = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if socialOpen}
|
||||||
|
<SocialImageModal
|
||||||
|
open={true}
|
||||||
|
title={item.title}
|
||||||
|
slug={item.slug}
|
||||||
|
endpointBase="/api/social/calendar"
|
||||||
|
downloadPrefix="termin"
|
||||||
|
onclose={() => (socialOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-stein-700);
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user