Update project configuration and enhance translation support. Added caching mechanism for CMS responses with a new script, updated .gitignore to include cache files, and introduced translation handling in various components. Enhanced package.json with new scripts for cache warming and deployment. Added new translation-related components and improved existing ones for better localization support.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type {
|
||||
CalendarBlockData,
|
||||
CalendarItemData,
|
||||
} from "../../lib/block-types";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
type EventCardItem = CalendarItemData & {
|
||||
date: Date | null;
|
||||
timeStr: string;
|
||||
};
|
||||
|
||||
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const raw = block.items ?? [];
|
||||
return raw
|
||||
.filter(
|
||||
(x): x is CalendarItemData =>
|
||||
typeof x === "object" &&
|
||||
x !== null &&
|
||||
"terminZeit" in x &&
|
||||
"title" in x,
|
||||
)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
date: parseDate(x.terminZeit),
|
||||
timeStr: formatTime(x.terminZeit),
|
||||
}))
|
||||
.filter((x) => x.date)
|
||||
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
|
||||
});
|
||||
|
||||
function parseDate(iso: string): Date | null {
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
|
||||
function getEventLinkHref(link: unknown): string {
|
||||
if (typeof link === "string" && link) return link;
|
||||
if (link && typeof link === "object") {
|
||||
const o = link as { url?: string; _slug?: string };
|
||||
if (typeof o.url === "string" && o.url) return o.url;
|
||||
if (typeof o._slug === "string" && o._slug)
|
||||
return `/post/${encodeURIComponent(o._slug)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
if (h === 0 && m === 0) return "";
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const eventsByDay = $derived.by(() => {
|
||||
const map = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
if (!item.date) continue;
|
||||
const key = dayKey(item.date);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(item);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let currentMonth = $state(new Date());
|
||||
let selectedDay = $state<string | null>(null);
|
||||
|
||||
const year = $derived(currentMonth.getFullYear());
|
||||
const month = $derived(currentMonth.getMonth());
|
||||
const monthLabel = $derived(
|
||||
currentMonth.toLocaleDateString("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const daysInMonth = $derived.by(() => {
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const count = last.getDate();
|
||||
const startWeekday = (first.getDay() + 6) % 7;
|
||||
return { count, startWeekday, last };
|
||||
});
|
||||
|
||||
const calendarDays = $derived.by(() => {
|
||||
const { count, startWeekday } = daysInMonth;
|
||||
const empty = Array(startWeekday).fill(null);
|
||||
const days = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => new Date(year, month, i + 1),
|
||||
);
|
||||
return [...empty, ...days];
|
||||
});
|
||||
|
||||
const selectedDayEvents = $derived(
|
||||
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
|
||||
);
|
||||
|
||||
/** Nächste 3 Termine ab heute – zur Laufzeit gefiltert (statische App). */
|
||||
const nextUpcomingEvents = $derived.by(() => {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const todayMs = startOfToday.getTime();
|
||||
return items
|
||||
.filter((item) => item.date && item.date.getTime() >= todayMs)
|
||||
.slice(0, 3);
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth = new Date(year, month - 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth = new Date(year, month + 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function selectDay(key: string) {
|
||||
selectedDay = selectedDay === key ? null : key;
|
||||
}
|
||||
|
||||
const todayKey = $derived(dayKey(new Date()));
|
||||
|
||||
const weekdays = $derived([
|
||||
t(T.calendar_weekday_mo),
|
||||
t(T.calendar_weekday_di),
|
||||
t(T.calendar_weekday_mi),
|
||||
t(T.calendar_weekday_do),
|
||||
t(T.calendar_weekday_fr),
|
||||
t(T.calendar_weekday_sa),
|
||||
t(T.calendar_weekday_so),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
||||
data-block-type="calendar"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#snippet eventCard(item: EventCardItem)}
|
||||
{@const eventHref = getEventLinkHref(item.link)}
|
||||
<li class="bg-himmel-100 p-2">
|
||||
<div class="flex gap-1">
|
||||
{#if item.date}
|
||||
<div class="text-xs text-stein-500 mb-0.5">
|
||||
{item.date.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
{#if item.timeStr}
|
||||
<span class="text-xs text-stein-600">/</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.timeStr}
|
||||
<div class="text-xs text-stein-600">
|
||||
{item.timeStr}
|
||||
{t(T.calendar_time_suffix)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
{:else}
|
||||
<div class="text-base font-medium text-stein-900">{item.title}</div>
|
||||
{/if}
|
||||
|
||||
{#if item.description}
|
||||
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
|
||||
{item.description}
|
||||
</p>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{:else if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
{#if block.title}
|
||||
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
|
||||
{block.title}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
|
||||
<!-- Links: Kalender (max 600px) -->
|
||||
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||||
aria-label={t(T.calendar_prev_month)}
|
||||
onclick={prevMonth}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
||||
>{monthLabel}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 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 class="grid grid-cols-7 gap-0.5 text-center text-sm">
|
||||
{#each weekdays as w}
|
||||
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
|
||||
{/each}
|
||||
{#each calendarDays as d}
|
||||
{#if d === null}
|
||||
<div class="aspect-square" aria-hidden="true"></div>
|
||||
{:else}
|
||||
{@const key = dayKey(d)}
|
||||
{@const isCurrentMonth = d.getMonth() === month}
|
||||
{@const events = eventsByDay.get(key) ?? []}
|
||||
{@const hasEvents = events.length > 0}
|
||||
{@const isSelected = selectedDay === key}
|
||||
{@const isFuture = key >= todayKey}
|
||||
{#if hasEvents}
|
||||
<button
|
||||
type="button"
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
|
||||
? 'text-himmel-100 hover:bg-himmel-700'
|
||||
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
|
||||
? 'ring-2 ring-himmel-400 bg-himmel-600'
|
||||
: ''}"
|
||||
onclick={() => selectDay(key)}
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{events.length}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
||||
? 'text-himmel-100'
|
||||
: 'text-himmel-100 opacity-50'}"
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechts: Wrapper relativ → Eventblock absolut (nur bei 2 Spalten, siehe global.css), Höhe = Kalender -->
|
||||
<div class="calendar-events-wrapper min-h-0">
|
||||
<div
|
||||
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{#if items.length === 0}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
|
||||
{:else if selectedDay}
|
||||
<p class="text-xs font-medium text-stein-500 mb-2">
|
||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
|
||||
{#each selectedDayEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if nextUpcomingEvents.length > 0}
|
||||
<p class="text-xs uppercase font-medium text-stein-500">
|
||||
{t(T.calendar_next_events)}
|
||||
</p>
|
||||
<ul
|
||||
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
|
||||
>
|
||||
{#each nextUpcomingEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,8 +2,14 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { IframeBlockData } from "../../lib/block-types";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
|
||||
let { block }: { block: IframeBlockData } = $props();
|
||||
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
|
||||
let overlayActive = $state(true);
|
||||
@@ -40,7 +46,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
|
||||
aria-label="Klicken zum Aktivieren der Karte"
|
||||
aria-label={t(T.iframe_activate_aria)}
|
||||
onclick={activateMap}
|
||||
onkeydown={(e) => e.key === "Enter" && activateMap()}
|
||||
>
|
||||
@@ -55,6 +61,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
|
||||
<p class="text-sm text-zinc-500">{t(T.iframe_missing)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,20 +4,33 @@
|
||||
|
||||
let { block }: { block: ImageBlockData } = $props();
|
||||
|
||||
function getUrl(b: ImageBlockData): string | null {
|
||||
if (typeof b.resolvedImageSrc === "string" && b.resolvedImageSrc) return b.resolvedImageSrc;
|
||||
const img = b.img ?? b.image;
|
||||
if (typeof img === "string" && img.startsWith("http")) return img;
|
||||
if (img && typeof img === "object") {
|
||||
const src = (img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
|
||||
if (typeof src === "string" && src) return src.startsWith("//") ? `https:${src}` : src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const imageSource = $derived(block.image ?? block.img);
|
||||
const imageUrl = $derived(
|
||||
block.resolvedImageSrc ??
|
||||
(typeof imageSource === "string"
|
||||
? imageSource.startsWith("http")
|
||||
? imageSource
|
||||
: null
|
||||
: imageSource && typeof imageSource === "object"
|
||||
? "src" in imageSource && typeof imageSource.src === "string"
|
||||
? imageSource.src
|
||||
: imageSource.file?.url ?? null
|
||||
: null),
|
||||
);
|
||||
const imageUrl = $derived(getUrl(block));
|
||||
$effect(() => {
|
||||
if (!imageUrl && block._slug) {
|
||||
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
|
||||
resolvedImageSrc: block.resolvedImageSrc,
|
||||
hasImage: !!block.image,
|
||||
hasImg: !!block.img,
|
||||
imageType: typeof block.image,
|
||||
imgType: typeof block.img,
|
||||
imgSrc: block.img && typeof block.img === "object" ? (block.img as { src?: string }).src : undefined,
|
||||
imageSrc: block.image && typeof block.image === "object" ? (block.image as { src?: string }).src : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
const title = $derived(
|
||||
typeof imageSource === "object" && imageSource
|
||||
? ("description" in imageSource && imageSource.description
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "../../lib/translations";
|
||||
import { T } from "../../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
@@ -105,7 +108,7 @@
|
||||
{/if}
|
||||
|
||||
{#if withUrl.length === 0}
|
||||
<p class="text-sm text-stein-500">Keine Bilder in der Galerie.</p>
|
||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<img
|
||||
@@ -123,7 +126,7 @@
|
||||
<div
|
||||
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
|
||||
role="region"
|
||||
aria-label="Galerie: wischen zum Wechseln"
|
||||
aria-label={t(T.image_gallery_swipe_aria)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
@@ -159,7 +162,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label="Vorheriges Bild"
|
||||
aria-label={t(T.image_gallery_prev_aria)}
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
@@ -167,18 +170,18 @@
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label="Nächstes Bild"
|
||||
aria-label={t(T.image_gallery_next_aria)}
|
||||
onclick={next}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label="Galerieposition">
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label="Bild {i + 1}"
|
||||
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? "bg-wald-600"
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.content && typeof block.content === "string"
|
||||
block.resolvedContent ??
|
||||
(block.content && typeof block.content === "string"
|
||||
? (marked.parse(block.content) as string)
|
||||
: "",
|
||||
: ""),
|
||||
);
|
||||
|
||||
const alignClass = $derived(
|
||||
|
||||
@@ -10,21 +10,23 @@
|
||||
import Tag from "../Tag.svelte";
|
||||
import Tags from "../Tags.svelte";
|
||||
import Tooltip from "../../lib/ui/Tooltip.svelte";
|
||||
|
||||
const DEFAULT_HELP_TOOLTIP =
|
||||
"Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren“ könnt ihr den Text in die Zwischenablage übernehmen (z. B. für Stellungnahmen).";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
|
||||
let {
|
||||
block,
|
||||
class: className = "",
|
||||
helpTooltip,
|
||||
translations = {},
|
||||
}: {
|
||||
block: SearchableTextBlockData;
|
||||
class?: string;
|
||||
helpTooltip?: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
const helpTooltipText = $derived(helpTooltip ?? DEFAULT_HELP_TOOLTIP);
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
@@ -33,11 +35,16 @@
|
||||
const fragments = $derived.by(() => {
|
||||
const raw = block.textFragments ?? [];
|
||||
return raw
|
||||
.filter((f): f is SearchableTextFragment => typeof f === "object" && f !== null && ("title" in f || "text" in f))
|
||||
.filter(
|
||||
(f): f is SearchableTextFragment =>
|
||||
typeof f === "object" && f !== null && ("title" in f || "text" in f),
|
||||
)
|
||||
.map((f) => {
|
||||
const tagsRaw = f.tags ?? [];
|
||||
const tags = tagsRaw
|
||||
.map((t) => (typeof t === "string" ? t : (t as { name?: string })?.name ?? ""))
|
||||
.map((t) =>
|
||||
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
title: (f.title ?? "").trim(),
|
||||
@@ -51,14 +58,19 @@
|
||||
});
|
||||
|
||||
const allTags = $derived(
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) => a.localeCompare(b)),
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let selectedTag = $state("all");
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
|
||||
async function copyFragmentContent(fragment: { title: string; text: string }, index: number) {
|
||||
async function copyFragmentContent(
|
||||
fragment: { title: string; text: string },
|
||||
index: number,
|
||||
) {
|
||||
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(plain);
|
||||
@@ -110,7 +122,10 @@
|
||||
if (!term.trim()) return escapeHtml(text);
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
return escapeHtml(text).replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
return escapeHtml(text).replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
|
||||
@@ -122,7 +137,10 @@
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith("<")) return part;
|
||||
return part.replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
return part.replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -153,50 +171,64 @@
|
||||
>
|
||||
<div class="p-4 md:p-6">
|
||||
{#if block.title}
|
||||
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">{block.title}</h2>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">
|
||||
{block.title}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">{@html descriptionHtml}</div>
|
||||
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none">
|
||||
<header
|
||||
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Tooltip content={helpTooltipText} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
|
||||
aria-label="Hilfe zur Suche anzeigen"
|
||||
aria-label={t(T.searchable_text_help_aria)}
|
||||
>
|
||||
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:help-circle-outline"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="In Titeln und Inhalten suchen…"
|
||||
placeholder={t(T.searchable_text_placeholder)}
|
||||
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label="Suche in Titeln und Inhalten"
|
||||
aria-label={t(T.searchable_text_search_aria)}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Suche leeren"
|
||||
aria-label={t(T.searchable_text_clear_search)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if allTags.length > 0}
|
||||
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
|
||||
<div class="flex gap-2 flex-nowrap min-w-max md:flex-wrap md:w-auto">
|
||||
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
|
||||
<Tag
|
||||
label="Alle"
|
||||
label={t(T.searchable_text_tag_all)}
|
||||
variant={selectedTag === "all" ? "green" : "white"}
|
||||
active={selectedTag === "all"}
|
||||
onclick={() => (selectedTag = "all")}
|
||||
@@ -215,14 +247,20 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200" aria-live="polite">
|
||||
<div
|
||||
class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
|
||||
<span>{t(T.searchable_text_no_results)}</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? `${visibleFragments.length} Einträge`
|
||||
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
|
||||
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
||||
: t(T.searchable_text_count_filtered, {
|
||||
visible: visibleFragments.length,
|
||||
total: fragments.length,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -230,10 +268,21 @@
|
||||
<div class="flex flex-col gap-0 bg-stein-100">
|
||||
{#each visibleFragments as fragment, i}
|
||||
<details class="group border-b border-stein-200 last:border-b-0">
|
||||
<summary class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors">
|
||||
<summary
|
||||
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="grow min-w-0">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
|
||||
<Icon icon="mdi:chevron-down" class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform" aria-hidden="true" />
|
||||
<div class="grow min-w-0">
|
||||
{@html highlightInText(
|
||||
fragment.title || t(T.searchable_text_untitled),
|
||||
searchQuery.trim(),
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1">
|
||||
@@ -241,20 +290,33 @@
|
||||
</span>
|
||||
{/if}
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs">
|
||||
<div
|
||||
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
|
||||
>
|
||||
<div class="flex justify-end mb-3 -mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
|
||||
onclick={(e) => { e.preventDefault(); copyFragmentContent(fragment, i); }}
|
||||
aria-label="Inhalt in Zwischenablage kopieren"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
|
||||
<span>Kopiert!</span>
|
||||
<Icon
|
||||
icon="mdi:check"
|
||||
class="size-4 text-wald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copied)}</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
|
||||
<span>Inhalt kopieren</span>
|
||||
<Icon
|
||||
icon="mdi:content-copy"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copy_button)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { YoutubeVideoBlockData } from "../../lib/block-types";
|
||||
import { useTranslate } from "../../lib/translations";
|
||||
import { T } from "../../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
@@ -16,7 +19,7 @@
|
||||
{#if block.youtubeId}
|
||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
|
||||
<iframe
|
||||
title={block.title ?? "YouTube-Video"}
|
||||
title={block.title ?? t(T.youtube_title_fallback)}
|
||||
src={embedUrl}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
@@ -28,6 +31,6 @@
|
||||
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-stein-500">YouTube-Video-ID fehlt.</div>
|
||||
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user