Enhance project structure and functionality by adding new components and integrations. Updated .gitignore to exclude history and transformed images. Integrated Svelte, sitemap, and icon support in astro.config.mjs. Added multiple new components including BlogOverview, ContentRows, Footer, Header, and various block components for improved content management. Updated package.json with new dependencies and modified scripts for build and development processes.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { HeadlineBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: HeadlineBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const alignClass = $derived(
|
||||
block.align === "center"
|
||||
? "text-center"
|
||||
: block.align === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="headline" data-block-slug={block._slug}>
|
||||
<svelte:element
|
||||
this={Tag}
|
||||
class={alignClass}
|
||||
>
|
||||
{block.text ?? ""}
|
||||
</svelte:element>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { HtmlBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: HtmlBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} content-html"
|
||||
data-block-type="html"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{@html block.html ?? ""}
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { IframeBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: IframeBlockData } = $props();
|
||||
|
||||
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
|
||||
let overlayActive = $state(true);
|
||||
|
||||
/** Nimmt entweder eine reine URL oder Iframe-HTML und liefert die src-URL. */
|
||||
function getIframeSrc(raw: string | undefined): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) return "";
|
||||
const s = raw.trim();
|
||||
if (s.startsWith("http") || s.startsWith("//")) return s;
|
||||
const match = s.match(/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const src = $derived(getIframeSrc(block.iframe));
|
||||
|
||||
function activateMap() {
|
||||
overlayActive = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="iframe" data-block-slug={block._slug}>
|
||||
{#if src}
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
|
||||
<iframe
|
||||
title={block.content ?? "Embed"}
|
||||
src={src}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{#if overlayActive}
|
||||
<div
|
||||
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"
|
||||
onclick={activateMap}
|
||||
onkeydown={(e) => e.key === "Enter" && activateMap()}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:cursor-default-click"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
class="block text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { ImageBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: ImageBlockData } = $props();
|
||||
|
||||
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 title = $derived(
|
||||
typeof imageSource === "object" && imageSource
|
||||
? ("description" in imageSource && imageSource.description
|
||||
? imageSource.description
|
||||
: "title" in imageSource
|
||||
? (imageSource as { title?: string }).title
|
||||
: undefined)
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
||||
{#if imageUrl}
|
||||
<figure class="max-w-[400px]">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={block.caption ?? title ?? ""}
|
||||
class="w-full rounded-sm"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if block.caption}
|
||||
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Bild nicht verfügbar.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
? (marked.parse(block.description) as string)
|
||||
: "",
|
||||
);
|
||||
const images = $derived(
|
||||
(block.images ?? []).filter((i): i is ImageGalleryImage => i != null && typeof i === "object"),
|
||||
);
|
||||
const withUrl = $derived(
|
||||
images
|
||||
.map((img) => ({ img, url: imageUrl(img) }))
|
||||
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
|
||||
);
|
||||
|
||||
let currentIndex = $state(0);
|
||||
|
||||
function imageUrl(img: ImageGalleryImage): string | null {
|
||||
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
|
||||
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function prev() {
|
||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||
}
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % withUrl.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown max-w-none prose prose-zinc mb-4">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if withUrl.length === 0}
|
||||
<p class="text-sm text-zinc-500">Keine Bilder in der Galerie.</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<img
|
||||
src={withUrl[0].url}
|
||||
alt={withUrl[0].img.description ?? ""}
|
||||
class="w-full rounded-sm object-cover aspect-4/3"
|
||||
loading="eager"
|
||||
/>
|
||||
{#if withUrl[0].img.description}
|
||||
<figcaption class="mt-1 text-sm text-zinc-600">{withUrl[0].img.description}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<div class="relative overflow-hidden rounded-sm bg-zinc-100 aspect-4/3">
|
||||
{#key currentIndex}
|
||||
{@const current = withUrl[currentIndex]}
|
||||
<figure
|
||||
class="m-0 absolute inset-0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.img.description ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading={currentIndex === 0 ? "eager" : "lazy"}
|
||||
/>
|
||||
{#if current.img.description}
|
||||
<figcaption
|
||||
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-sm text-white bg-black/50 rounded-b-sm"
|
||||
>
|
||||
{current.img.description}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Vorheriges Bild"
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Nächstes Bild"
|
||||
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">
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label="Bild {i + 1}"
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? "bg-green-600"
|
||||
: "bg-zinc-300 hover:bg-zinc-400"}"
|
||||
onclick={() => (currentIndex = i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { LinkListBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: LinkListBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(
|
||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||
);
|
||||
const links = $derived(block.links ?? []);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="link_list" data-block-slug={block._slug}>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2 font-semibold text-zinc-900">{block.headline}</h3>
|
||||
{/if}
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{#each links as link}
|
||||
{#if typeof link === "object" && link !== null && "url" in link}
|
||||
<li>
|
||||
<a
|
||||
href={(link as { url?: string }).url ?? "#"}
|
||||
class="underline text-green-700 hover:text-green-800"
|
||||
target={(link as { newTab?: boolean }).newTab ? "_blank" : undefined}
|
||||
rel={(link as { newTab?: boolean }).newTab ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{(link as { linkName?: string }).linkName ?? (link as { url?: string }).url}
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<span class="text-zinc-500">{typeof link === "string" ? link : "Link"}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { MarkdownBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.content && typeof block.content === "string"
|
||||
? (marked.parse(block.content) as string)
|
||||
: "",
|
||||
);
|
||||
|
||||
const alignClass = $derived(
|
||||
block.alignment === "center"
|
||||
? "text-center"
|
||||
: block.alignment === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={layoutClasses}
|
||||
data-block-type="markdown"
|
||||
data-markdown-id={block.name}
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="markdown max-w-none {alignClass} {className}">
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import PostCard from "../PostCard.svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { PostOverviewBlockData } from "../../lib/block-types";
|
||||
import type { PostEntry } from "../../lib/cms";
|
||||
import { postHref } from "../../lib/blog-utils";
|
||||
|
||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const textHtml = $derived(
|
||||
block.text && typeof block.text === "string"
|
||||
? (marked.parse(block.text) as string)
|
||||
: "",
|
||||
);
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]);
|
||||
const design = $derived(block.design ?? "cards");
|
||||
const tagBase = "/posts/tag";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="post-overview mb-4 {layoutClasses} {className}"
|
||||
data-block-type="post_overview"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2">{block.headline}</h3>
|
||||
{/if}
|
||||
{#if textHtml}
|
||||
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
|
||||
{/if}
|
||||
|
||||
{#if design === "list" && posts.length > 0}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if design === "cards" && posts.length > 0}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { QuoteBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: QuoteBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const variantClass = $derived(block.variant === "right" ? "text-right" : "text-left");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
|
||||
<blockquote class="border-l-4 border-green-700 pl-4 {variantClass}">
|
||||
<p class="text-lg italic text-zinc-700">"{block.quote ?? ""}"</p>
|
||||
{#if block.author}
|
||||
<cite class="mt-1 block not-italic text-sm text-zinc-500">— {block.author}</cite>
|
||||
{/if}
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
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";
|
||||
|
||||
let { block, class: className = "" }: { block: SearchableTextBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
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");
|
||||
|
||||
/** Wird im Template aufgerufen, damit Reaktivität auf searchQuery/selectedTag sicher greift. */
|
||||
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-amber-200 rounded px-0.5";
|
||||
|
||||
/** Suchbegriff in Plain-Text hervorheben (HTML-escaped, Treffer in <mark>). */
|
||||
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>`);
|
||||
}
|
||||
|
||||
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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-gray-200 bg-slate-50 {layoutClasses} {className}"
|
||||
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 bg-slate-50 mb-2">{block.title}</h2>
|
||||
{/if}
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown text-zinc-600 max-w-none prose prose-zinc prose-sm bg-slate-50">{@html descriptionHtml}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-4 md:px-6">
|
||||
<div class="inline-flex bg-amber-100 border border-amber-200/60 text-xs p-3 text-amber-800 items-start gap-2 mb-4 rounded">
|
||||
<span class="shrink-0" aria-hidden="true">ℹ</span>
|
||||
<span>
|
||||
Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-slate-300 bg-white/90 backdrop-blur-sm md:static md:bg-slate-200 md:backdrop-blur-none shadow-md md:shadow-none">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="In Titeln und Inhalten suchen…"
|
||||
class="w-full text-sm px-4 py-2 pr-10 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-transparent bg-white"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label="Suche in Titeln und Inhalten"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-gray-200 text-gray-500"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Suche leeren"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-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">
|
||||
<Tag
|
||||
label="Alle"
|
||||
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-slate-600 px-4 md:px-6 py-2 bg-slate-200 border-b border-slate-300" aria-live="polite">
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? `${visibleFragments.length} Einträge`
|
||||
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0 bg-slate-200">
|
||||
{#each visibleFragments as fragment}
|
||||
<details class="group border-b border-slate-300 last:border-b-0">
|
||||
<summary class="cursor-pointer list-none px-4 md:px-6 py-3 bg-slate-100 hover:bg-slate-50 text-sm font-medium flex items-start gap-2">
|
||||
<div class="grow">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1 justify-end">
|
||||
<Tags tags={fragment.tags} variant="white" />
|
||||
</span>
|
||||
{/if}
|
||||
<Icon icon="mdi:chevron-down" class="shrink-0 text-slate-400 group-open:rotate-180 transition-transform" aria-hidden="true" />
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 py-4 bg-white markdown prose prose-zinc prose-sm max-w-none">
|
||||
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { YoutubeVideoBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const embedUrl = $derived(
|
||||
block.youtubeId
|
||||
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
|
||||
{#if block.youtubeId}
|
||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
|
||||
<iframe
|
||||
title={block.title ?? "YouTube-Video"}
|
||||
src={embedUrl}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
{#if block.description}
|
||||
<p class="mt-1 text-sm text-zinc-600">{block.description}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">YouTube-Video-ID fehlt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user