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:
@@ -17,6 +17,7 @@
|
||||
"firebase-deploy:nocache": "yarn build && firebase deploy --only hosting"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/svelte": "^8.0.4",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
{/if}
|
||||
|
||||
{#if posts.length > 0}
|
||||
<div class="mt-3 text-sm">
|
||||
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline">
|
||||
<div class="mt-3 text-xs px-2">
|
||||
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline !no-underline">
|
||||
{t(T.post_overview_all)}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
|
||||
<blockquote class="border-l-4 border-wald-700 pl-4 {variantClass}">
|
||||
<p class="text-xl font-semibold text-wald-700">{block.quote ?? ""}</p>
|
||||
<blockquote class="border-l-2 border-wald-400 pl-4 {variantClass}">
|
||||
<p class="text-lg font-extralight text-wald-600">{block.quote ?? ""}</p>
|
||||
{#if block.author}
|
||||
<cite class="mt-1 block not-italic text-sm text-wald-300">{block.author}</cite>
|
||||
<cite class="block font-sans! text-xs text-wald-400"> » {block.author}</cite>
|
||||
{/if}
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
@@ -460,6 +460,7 @@ const cmsFromCache = getCmsFromCache();
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
||||
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="icon"
|
||||
|
||||
@@ -49,6 +49,30 @@ const filtered = filterPostsByTag(posts, null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pagePosts = paginate(filtered, 1, perPage);
|
||||
|
||||
type PostWithEvent = (typeof posts)[number] & {
|
||||
isEvent?: boolean;
|
||||
eventDate?: string;
|
||||
_resolvedImageUrl?: string;
|
||||
};
|
||||
const nowMs = Date.now();
|
||||
const upcomingEvents = (posts as PostWithEvent[])
|
||||
.filter((p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= nowMs)
|
||||
.sort((a, b) => new Date(a.eventDate ?? 0).getTime() - new Date(b.eventDate ?? 0).getTime())
|
||||
.slice(0, 4);
|
||||
|
||||
// Lightweight index for client-side search over the full archive.
|
||||
const searchIndex = posts.map((p) => ({
|
||||
slug: p.slug ?? p._slug ?? "",
|
||||
headline: p.headline ?? "",
|
||||
excerpt: p.excerpt ?? "",
|
||||
subheadline: p.subheadline ?? "",
|
||||
tags: (p.postTag ?? []).map((t) => (typeof t === "string" ? t : (t as { name?: string; _slug?: string }).name ?? (t as { _slug?: string })._slug ?? "")).filter(Boolean),
|
||||
image: (p as PostWithEvent)._resolvedImageUrl ?? null,
|
||||
created: p.created ?? null,
|
||||
isEvent: Boolean((p as PostWithEvent).isEvent),
|
||||
eventDate: (p as PostWithEvent).eventDate ?? null,
|
||||
}));
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
@@ -79,6 +103,9 @@ const translations = Astro.locals.translations ?? {};
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
translations={translations}
|
||||
upcomingEvents={upcomingEvents}
|
||||
searchIndex={searchIndex}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import type { APIContext } from "astro";
|
||||
import { getPosts } from "../../lib/cms";
|
||||
import { sortPostsByDate, filterHiddenPosts, postHref } from "../../lib/blog-utils";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const site = context.site ?? new URL("https://windwiderstand.de");
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
try {
|
||||
posts = filterHiddenPosts(sortPostsByDate(await getPosts()));
|
||||
} catch {
|
||||
posts = [];
|
||||
}
|
||||
|
||||
return rss({
|
||||
title: "Bürgerinitiative Vachdorf – Beiträge",
|
||||
description: "Aktuelles zur Windkraft, Gesundheit und Region.",
|
||||
site,
|
||||
items: posts.map((p) => {
|
||||
const createdRaw = (p as { _created?: string; created?: string }).created ?? (p as { _created?: string })._created;
|
||||
const pubDate = createdRaw ? new Date(createdRaw) : undefined;
|
||||
return {
|
||||
title: p.headline ?? p.slug ?? "",
|
||||
description: p.excerpt ?? p.subheadline ?? "",
|
||||
link: postHref(p),
|
||||
pubDate: pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate : undefined,
|
||||
};
|
||||
}),
|
||||
customData: `<language>de-de</language>`,
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,15 @@
|
||||
dependencies:
|
||||
prismjs "^1.30.0"
|
||||
|
||||
"@astrojs/rss@^4.0.18":
|
||||
version "4.0.18"
|
||||
resolved "https://registry.yarnpkg.com/@astrojs/rss/-/rss-4.0.18.tgz#9ce80003be5668545418d7e6ad53fe00e7a94d16"
|
||||
integrity sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==
|
||||
dependencies:
|
||||
fast-xml-parser "^5.5.7"
|
||||
piccolore "^0.1.3"
|
||||
zod "^4.3.6"
|
||||
|
||||
"@astrojs/sitemap@^3.7.2":
|
||||
version "3.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@astrojs/sitemap/-/sitemap-3.7.2.tgz#6647a3f42f75435970abf72d456e1e9d8360dcdc"
|
||||
@@ -2484,6 +2493,22 @@ fast-uri@^3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
|
||||
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
|
||||
|
||||
fast-xml-builder@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017"
|
||||
integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==
|
||||
dependencies:
|
||||
path-expression-matcher "^1.1.3"
|
||||
|
||||
fast-xml-parser@^5.5.7:
|
||||
version "5.5.12"
|
||||
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz#6e50e5a5bbb03c1dc72a9268a9bfe677b1b623b2"
|
||||
integrity sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==
|
||||
dependencies:
|
||||
fast-xml-builder "^1.1.4"
|
||||
path-expression-matcher "^1.5.0"
|
||||
strnum "^2.2.3"
|
||||
|
||||
faye-websocket@0.11.4:
|
||||
version "0.11.4"
|
||||
resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da"
|
||||
@@ -3657,6 +3682,11 @@ parse5@^7.0.0, parse5@^7.3.0:
|
||||
dependencies:
|
||||
entities "^6.0.0"
|
||||
|
||||
path-expression-matcher@^1.1.3, path-expression-matcher@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a"
|
||||
integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==
|
||||
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||
@@ -4177,6 +4207,11 @@ strip-indent@^3.0.0:
|
||||
dependencies:
|
||||
min-indent "^1.0.0"
|
||||
|
||||
strnum@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586"
|
||||
integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==
|
||||
|
||||
supports-color@^10.2.2:
|
||||
version "10.2.2"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4"
|
||||
|
||||
Reference in New Issue
Block a user