feat(posts): search, upcoming events, RSS feed
Deploy to Firebase Hosting / deploy (push) Has been cancelled
Deploy to Firebase Hosting / deploy (push) Has been cancelled
- Client-side search on /posts/ (full archive, ~2-char threshold) - Upcoming-events block at top of /posts/ from isEvent+eventDate posts - RSS feed at /posts/rss.xml via @astrojs/rss - Layout head: rss autodiscovery link - RSS subscribe link below the listing - Style tweaks on QuoteBlock and PostOverviewBlock
This commit is contained in:
@@ -14,6 +14,20 @@
|
||||
color?: string;
|
||||
}
|
||||
|
||||
type SearchIndexEntry = {
|
||||
slug: string;
|
||||
headline: string;
|
||||
excerpt: string;
|
||||
subheadline: string;
|
||||
tags: string[];
|
||||
image: string | null;
|
||||
created: string | null;
|
||||
isEvent: boolean;
|
||||
eventDate: string | null;
|
||||
};
|
||||
|
||||
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
|
||||
|
||||
let {
|
||||
posts = [],
|
||||
tags = [],
|
||||
@@ -23,6 +37,8 @@
|
||||
totalPosts = 0,
|
||||
basePath = "/posts",
|
||||
translations = {},
|
||||
upcomingEvents = [],
|
||||
searchIndex = null,
|
||||
}: {
|
||||
posts: PostEntry[];
|
||||
tags: TagEntry[];
|
||||
@@ -32,8 +48,37 @@
|
||||
totalPosts?: number;
|
||||
basePath?: string;
|
||||
translations?: Translations | null;
|
||||
upcomingEvents?: EventPost[];
|
||||
searchIndex?: SearchIndexEntry[] | null;
|
||||
} = $props();
|
||||
|
||||
let query = $state("");
|
||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
||||
const isSearching = $derived(normalizedQuery.length >= 2);
|
||||
|
||||
const searchResults = $derived.by<SearchIndexEntry[]>(() => {
|
||||
if (!isSearching || !searchIndex) return [];
|
||||
const q = normalizedQuery;
|
||||
return searchIndex
|
||||
.filter((p) => {
|
||||
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 40);
|
||||
});
|
||||
|
||||
function searchResultHref(slug: string): string {
|
||||
const norm = (slug ?? "").replace(/^\//, "").trim();
|
||||
return norm ? `/post/${encodeURIComponent(norm)}` : "/post";
|
||||
}
|
||||
|
||||
function formatEventDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Immer Übersetzungen von der Astro-Seite nutzen (SSR + Insel), damit Keys wie blog_tag_all ankommen. */
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
@@ -48,6 +93,74 @@
|
||||
</script>
|
||||
|
||||
<div class="blog-overview">
|
||||
{#if searchIndex}
|
||||
<div class="mb-4">
|
||||
<label class="relative block">
|
||||
<span class="sr-only">Suche</span>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Beiträge durchsuchen…"
|
||||
class="w-full rounded-sm border border-zinc-300 bg-white px-3 py-2 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (query = "")}
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900"
|
||||
aria-label="Suche leeren"
|
||||
>×</button>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isSearching && upcomingEvents.length > 0}
|
||||
<section class="mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4">
|
||||
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-700 uppercase">Nächste Termine</h2>
|
||||
<ul class="space-y-2">
|
||||
{#each upcomingEvents as ev}
|
||||
<li>
|
||||
<a href={postHref(ev)} class="group flex items-baseline gap-3 no-underline hover:underline">
|
||||
<time class="shrink-0 text-xs font-medium text-zinc-600 tabular-nums">
|
||||
{formatEventDate(ev.eventDate)}
|
||||
</time>
|
||||
<span class="text-sm text-zinc-900 group-hover:text-zinc-950">{ev.headline}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if isSearching}
|
||||
<div class="mb-3 text-xs text-zinc-600">
|
||||
{searchResults.length} Treffer für „{query}"
|
||||
</div>
|
||||
{#if searchResults.length > 0}
|
||||
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each searchResults as hit}
|
||||
<a href={searchResultHref(hit.slug)} class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all">
|
||||
{#if hit.image}
|
||||
<div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
|
||||
<img src={hit.image} alt={hit.headline} class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<h3 class="mb-1 text-base font-semibold">{hit.headline}</h3>
|
||||
{#if hit.excerpt}
|
||||
<p class="text-sm text-zinc-700 line-clamp-3">{hit.excerpt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
|
||||
Keine Treffer. Versuche einen anderen Suchbegriff.
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
|
||||
@@ -117,4 +230,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-zinc-600">
|
||||
<a href="/posts/rss.xml" class="hover:underline">RSS-Feed abonnieren</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user