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:
Peter Meier
2026-03-17 22:32:31 +01:00
parent 1f5454d04e
commit daa4ea17b1
46 changed files with 5207 additions and 781 deletions
+349
View File
@@ -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>
+9 -3
View File
@@ -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>
+25 -12
View File
@@ -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"
+3 -2
View File
@@ -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>