Files
windwiderstand/src/lib/components/blocks/SearchableTextBlock.svelte
T
Peter Meier 5a86ff701e feat(blocks): data-block PascalCase identifier on every CMS block
Adds data-block="PascalName" alongside existing data-block-type
snake_case so blocks can be located via [data-block="Calendar"] etc.
in DevTools. Snake-case attribute kept for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:40 +02:00

323 lines
11 KiB
Svelte

<script lang="ts">
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
SearchableTextBlockData,
SearchableTextFragment,
} from "$lib/block-types";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import Tag from "../Tag.svelte";
import Tags from "../Tags.svelte";
import Tooltip from "$lib/ui/Tooltip.svelte";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let {
block,
class: className = "",
translations = {},
}: {
block: SearchableTextBlockData;
class?: string;
translations?: Translations | null;
} = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const helpTooltipText = $derived(t(T.searchable_text_help));
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
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),
)
.map((f) => {
const tagsRaw = f.tags ?? [];
const tags = tagsRaw
.map((t) =>
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
)
.filter(Boolean);
return {
title: (f.title ?? "").trim(),
text: (f.text ?? "").trim(),
tags,
textHtml: (f.text ?? "").trim()
? (marked.parse((f.text ?? "").trim()) as string)
: "",
};
});
});
const allTags = $derived(
[...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,
) {
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(plain);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
try {
const ta = document.createElement("textarea");
ta.value = plain;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
/* ignore */
}
}
}
function getVisibleFragments() {
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
const tag = selectedTag;
return fragments.filter((f) => {
const tagMatch = tag === "all" || f.tags.includes(tag);
if (!tagMatch) return false;
if (q === "") return true;
const titleNorm = (f.title ?? "").toLowerCase().replace(/\s+/g, " ");
const textNorm = (f.text ?? "")
.toLowerCase()
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ");
return titleNorm.includes(q) || textNorm.includes(q);
});
}
const visibleFragments = $derived(getVisibleFragments());
const HIGHLIGHT_CLASS = "bg-wald-200 text-wald-900 rounded px-0.5";
function highlightInText(text: string, term: string): string {
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>`,
);
}
function highlightInHtml(html: string, term: string): string {
if (!term.trim()) return html;
const escaped = escapeRegex(term);
const re = new RegExp(escaped, "gi");
const parts = html.split(/(<[^>]+>)/g);
return parts
.map((part) => {
if (part.startsWith("<")) return part;
return part.replace(
re,
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
);
})
.join("");
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const descriptionHtml = $derived(
block.description && typeof block.description === "string"
? (marked.parse(block.description) as string)
: "",
);
</script>
<div
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
data-block="SearchableText" data-block-type="searchable_text"
data-block-slug={block._slug}
>
<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>
{/if}
{#if descriptionHtml}
<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"
>
<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-xs 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={t(T.searchable_text_help_aria)}
>
<Icon
icon="mdi:help-circle-outline"
class="size-5"
aria-hidden="true"
/>
</button>
</Tooltip>
<input
type="search"
placeholder={t(T.searchable_text_placeholder)}
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-xs 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={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-xs hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
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"
/>
</div>
{#if allTags.length > 0}
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
<Tag
label={t(T.searchable_text_tag_all)}
variant={selectedTag === "all" ? "green" : "white"}
active={selectedTag === "all"}
onclick={() => (selectedTag = "all")}
/>
{#each allTags as tag}
<Tag
label={tag}
variant={selectedTag === tag ? "green" : "white"}
active={selectedTag === tag}
onclick={() => (selectedTag = tag)}
/>
{/each}
</div>
</div>
{/if}
</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"
>
{#if visibleFragments.length === 0}
<span>{t(T.searchable_text_no_results)}</span>
{:else}
<span>
{visibleFragments.length === fragments.length
? t(T.searchable_text_count_all, { count: visibleFragments.length })
: t(T.searchable_text_count_filtered, {
visible: visibleFragments.length,
total: fragments.length,
})}
</span>
{/if}
</div>
<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"
>
<div class="flex items-start gap-2 min-w-0">
<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">
<Tags tags={fragment.tags} variant="white" />
</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="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-xs 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={t(T.searchable_text_copy_aria)}
>
{#if copiedIndex === i}
<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>{t(T.searchable_text_copy_button)}</span>
{/if}
</button>
</div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
{/each}
</div>
</div>