Blog-Übersicht: Featured-Hero, Neu-Badge, Org-Sektion, Sortierung, konsolidiertes Suchfeld
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m31s

- SearchField-Atom (src/lib/ui/SearchField.svelte, light/dark) in Kalender,
  Beiträge und globaler Suche einheitlich eingesetzt
- Featured-Hero (neuester Beitrag groß, Seite 1 ungefiltert, nur bei "newest")
- Neu-Badge in PostCard (Beitrag < 7 Tage)
- Org-/BI-Tags (tag-org-*) in eigene Sektion; Tags ohne Beiträge ausgeblendet
- Inline-Newsletter-CTA, prominenter RSS-Link
- Sortierung Neueste/Älteste (Server-Param + URL-State, Toggle neben Pagination,
  Pagination trägt sort weiter)
- Pagination als Pills (rounded-full)
- Kalender: Tag-Filter einklappbar, Filterleiste mit Suche + Heute

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-02 10:40:07 +02:00
parent f86c302d49
commit e96bfee317
16 changed files with 526 additions and 131 deletions
+10 -1
View File
@@ -270,6 +270,8 @@ export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[]
});
}
export type PostSortOrder = "newest" | "oldest";
export type LoadPostsResult = {
posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>;
@@ -277,6 +279,7 @@ export type LoadPostsResult = {
currentPage: number;
totalPages: number;
totalPosts: number;
sort: PostSortOrder;
upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[];
tagName: string | null;
@@ -287,9 +290,11 @@ export type LoadPostsResult = {
export async function loadPostsList(opts: {
tagSlug?: string | null;
page?: number;
sort?: PostSortOrder;
}): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1);
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
const perPage = getPostsPerPage();
let posts: PostEntry[] = [];
@@ -351,7 +356,10 @@ export async function loadPostsList(opts: {
cmsError = e instanceof Error ? e.message : String(e);
}
const filtered = filterPostsByTag(posts, tagSlug);
const filteredByTag = filterPostsByTag(posts, tagSlug);
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
const filtered =
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
@@ -370,6 +378,7 @@ export async function loadPostsList(opts: {
currentPage: pageNum,
totalPages,
totalPosts: filtered.length,
sort,
upcomingEvents,
searchIndex,
tagName,
+280 -61
View File
@@ -5,8 +5,23 @@
import EventBadges from "./EventBadges.svelte";
import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte";
import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import PostMeta from "./PostMeta.svelte";
import RustyImage from "./RustyImage.svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import NewsletterInlineFormComponent from "./internal/NewsletterInlineFormComponent.svelte";
import type { PostEntry } from "$lib/cms";
import { postHref, getPostEventInfo } from "$lib/blog-utils";
import type { PostSortOrder } from "$lib/blog-utils";
import { marked } from "$lib/markdown-safe";
import { goto } from "$app/navigation";
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import {
@@ -45,6 +60,7 @@
translations = {},
upcomingEvents = [],
searchIndex = null,
sort = "newest",
}: {
posts: PostEntry[];
tags: TagEntry[];
@@ -56,10 +72,79 @@
translations?: Translations | null;
upcomingEvents?: PostEntry[];
searchIndex?: SearchIndexEntry[] | null;
sort?: PostSortOrder;
} = $props();
// Sortierung umschalten: Query-Param `sort`, immer zurück auf Seite 1
// (bei umgekehrter Reihenfolge stimmen alte Seitenzahlen nicht mehr).
function setSort(order: PostSortOrder) {
if (order === sort) return;
const base = activeTag
? `${basePath}/tag/${encodeURIComponent(activeTag)}/`
: `${basePath}/`;
goto(order === "oldest" ? `${base}?sort=oldest` : base);
}
let query = $state("");
let year = $state<string>("");
// Nur Tags anzeigen, die auch in mindestens einem Beitrag vorkommen
// (searchIndex = alle listbaren Posts, tags als Namen). Aktiver Tag bleibt
// immer sichtbar. Ohne searchIndex nichts filtern.
const usedTagNames = $derived(
new Set((searchIndex ?? []).flatMap((e) => e.tags)),
);
const isTagUsed = (t: TagEntry) =>
!searchIndex ||
usedTagNames.has(t.name) ||
(t._slug ?? "") === activeTag;
// Org-/BI-Tags (slug `tag-org-*`, name „Org · …") in eigene Sektion trennen.
const isOrgTag = (t: TagEntry) => (t._slug ?? "").startsWith("tag-org-");
const orgTags = $derived(tags.filter((t) => isOrgTag(t) && isTagUsed(t)));
const topicTags = $derived(
tags.filter((t) => !isOrgTag(t) && isTagUsed(t)),
);
const orgLabel = (name: string) => name.replace(/^Org\s*·\s*/, "");
const activeIsOrg = $derived(
!!activeTag && orgTags.some((t) => (t._slug ?? "") === activeTag),
);
// Lange Themen-Liste einklappen (erste N + „mehr"). Der aktive Tag wird
// auch im eingeklappten Zustand immer angezeigt.
const INITIAL_TAGS = 12;
let showAllTags = $state(false);
const visibleTags = $derived.by(() => {
if (showAllTags) return topicTags;
const head = topicTags.slice(0, INITIAL_TAGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = topicTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
let showAllOrgs = $state(false);
const INITIAL_ORGS = 10;
const visibleOrgs = $derived.by(() => {
if (showAllOrgs) return orgTags;
const head = orgTags.slice(0, INITIAL_ORGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = orgTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
// Featured-Hero: neuester Beitrag groß, nur auf Seite 1 der ungefilterten
// Übersicht. Danach die restlichen Posts im Grid.
const showFeatured = $derived(
currentPage === 1 && !activeTag && sort === "newest" && posts.length > 1,
);
const featuredPost = $derived(showFeatured ? posts[0] : null);
const gridPosts = $derived(showFeatured ? posts.slice(1) : posts);
const featuredEvent = $derived(
featuredPost ? getPostEventInfo(featuredPost) : null,
);
const normalizedQuery = $derived(query.trim().toLowerCase());
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
@@ -128,30 +213,12 @@
<div class="blog-overview">
{#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm">
<span class="sr-only">{t(T.blog_search_label)}</span>
<span
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
>
<Icon icon="lucide:search" class="h-4 w-4" />
</span>
<input
type="search"
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
class="w-full rounded-xs border border-stein-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-stein-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-xs p-1 text-stein-500 hover:bg-stein-100 hover:text-stein-900"
aria-label={t(T.blog_search_clear)}
>
<Icon icon="lucide:x" class="h-4 w-4" />
</button>
{/if}
</label>
<SearchField
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
ariaLabel={t(T.blog_search_label)}
class="w-full sm:max-w-sm"
/>
{#if availableYears.length > 0}
<label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span>
@@ -162,7 +229,7 @@
</span>
<select
bind:value={year}
class="appearance-none rounded-xs border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-stein-500 focus:outline-none"
class="appearance-none rounded-md border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
>
<option value="">{t(T.blog_year_all)}</option>
{#each availableYears as y}
@@ -180,11 +247,9 @@
{/if}
{#if !isFiltering && upcomingEvents.length > 0}
<section
class="not-prose mb-6 rounded-xs border border-stein-200 bg-stein-50 p-4"
>
<Card variant="callout" class="not-prose mb-6">
<h2
class="mb-3 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700"
class="mb-1 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700"
>
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
{t(T.blog_upcoming_events)}
@@ -206,7 +271,7 @@
</li>
{/each}
</ul>
</section>
</Card>
{/if}
{#if isFiltering}
@@ -233,9 +298,10 @@
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{#each filteredResults as hit}
<a
<Card
variant="tile"
layout="post"
href={searchResultHref(hit.slug)}
class="card-surface flex flex-col overflow-hidden text-stein-900 no-underline transition-colors hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 transition-[transform,box-shadow,border-color] duration-200"
>
{#if hit.image}
<div
@@ -259,7 +325,7 @@
</p>
{/if}
</div>
</a>
</Card>
{/each}
</div>
{:else}
@@ -270,7 +336,7 @@
</div>
{/if}
{:else}
{#if tags.length > 0}
{#if topicTags.length > 0}
<div class="mb-2">
<div
class="flex flex-wrap gap-2"
@@ -283,7 +349,7 @@
variant={activeTag ? "blue" : "green"}
active={!activeTag}
/>
{#each tags as tag}
{#each visibleTags as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={tag.name}
@@ -296,14 +362,161 @@
: tag.color?.trim() || null}
/>
{/each}
{#if topicTags.length > INITIAL_TAGS}
<Tag
label={showAllTags
? "weniger"
: `+${topicTags.length - INITIAL_TAGS} weitere`}
variant="white"
icon={showAllTags ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllTags = !showAllTags)}
/>
{/if}
</div>
</div>
{/if}
<Pagination {currentPage} {totalPages} {basePath} {activeTag} />
{#if orgTags.length > 0}
<div class="mb-4 mt-5">
<div
class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-stein-500"
>
<Icon icon="lucide:users" class="size-3.5" />
Bürgerinitiativen &amp; Organisationen
</div>
<div
class="flex flex-wrap gap-2"
role="navigation"
aria-label="Nach Organisation filtern"
>
{#each visibleOrgs as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={orgLabel(tag.name)}
href={tagHref(slug)}
variant={activeTag === slug ? "inactive" : "white"}
active={activeTag === slug}
icon={tag.icon?.trim() || "lucide:flag"}
/>
{/each}
{#if orgTags.length > INITIAL_ORGS && !activeIsOrg}
<Tag
label={showAllOrgs
? "weniger"
: `+${orgTags.length - INITIAL_ORGS} weitere`}
variant="white"
icon={showAllOrgs ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllOrgs = !showAllOrgs)}
/>
{/if}
</div>
</div>
{/if}
<div class="mt-5 flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<div
class="inline-flex overflow-hidden rounded-full border border-stein-200 bg-white text-xs"
role="group"
aria-label="Sortierung"
>
<button
type="button"
onclick={() => setSort("newest")}
aria-pressed={sort === "newest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'newest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Neueste
</button>
<button
type="button"
onclick={() => setSort("oldest")}
aria-pressed={sort === "oldest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'oldest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Älteste
</button>
</div>
</div>
{#if featuredPost}
{@const fp = featuredPost as PostWithImage}
<a
href={postHref(fp)}
class="not-prose group mb-6 grid overflow-hidden rounded-sm border border-stein-200 bg-white no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2 md:grid-cols-2"
>
<div class="relative aspect-video w-full overflow-hidden bg-stein-100 md:aspect-auto">
{#if fp._rawImageUrl}
<RustyImage
src={fp._rawImageUrl}
focal={fp._imageFocal ?? null}
width={720}
aspect="16/9"
alt={fp.headline ?? ""}
sizes="(min-width: 768px) 50vw, 100vw"
class="h-full w-full object-cover"
/>
{:else if fp._resolvedImageUrl}
<img
src={fp._resolvedImageUrl}
alt={fp.headline ?? ""}
class="h-full w-full object-cover"
/>
{/if}
<span
class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-wald-600 px-2.5 py-1 text-[.65rem] font-semibold uppercase tracking-wide text-white shadow-sm"
>
<Icon icon="lucide:star" class="size-3" />
Neuester Beitrag
</span>
{#if featuredEvent?.eventDate}
<div class="absolute bottom-3 left-3 right-3 min-w-0">
<EventBadges
eventDate={featuredEvent.eventDate}
locationText={featuredEvent.eventLocation?.text ?? null}
/>
</div>
{/if}
</div>
<div class="flex flex-col justify-center p-5 md:p-6">
<PostMeta
date={fp.created ?? null}
tags={fp.postTag ?? []}
tagBase={`${basePath}/tag`}
/>
<h2 class="mb-2 mt-1 text-xl font-semibold leading-tight text-stein-900 md:text-2xl">
{fp.headline ?? fp.linkName ?? fp.slug ?? fp._slug}
</h2>
{#if fp.subheadline}
<div class="mb-2 text-sm text-stein-700">
{@html marked.parseInline(fp.subheadline)}
</div>
{/if}
{#if fp.excerpt}
<p class="line-clamp-3 text-sm font-light text-stein-600">
{fp.excerpt}
</p>
{/if}
<span
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-wald-700 group-hover:gap-1.5"
>
Weiterlesen
<Icon
icon="lucide:arrow-right"
class="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</a>
{/if}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post}
{#each gridPosts as post}
<PostCard
{post}
href={postHref(post)}
@@ -314,36 +527,42 @@
{/each}
</div>
<div class="flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} />
<div class="grow"></div>
<div class="bg-white rounded-xs font-light">
<div class="inline-flex text-xs p-2">
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
{#if currentPage === 1 && !activeTag}
<div
class="not-prose my-6 rounded-sm bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
>
<NewsletterInlineFormComponent />
</div>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<Badge>
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</Badge>
</div>
<div class="mt-4 text-xs text-stein-600">
<div class="mt-5">
<a
href="/posts/rss.xml"
class="inline-flex items-center gap-1 hover:underline"
class="inline-flex items-center gap-2 rounded-full border border-stein-200 bg-white px-3.5 py-2 text-sm font-medium text-stein-700 no-underline transition-colors hover:border-wald-300 hover:bg-stein-50 hover:text-wald-700"
>
<Icon icon="lucide:rss" class="h-3.5 w-3.5" />
<Icon icon="lucide:rss" class="size-4 text-wald-600" />
{t(T.blog_rss_subscribe)}
</a>
</div>
+8 -4
View File
@@ -11,16 +11,20 @@
totalPages = 1,
basePath = "/posts",
activeTag = null,
sort = "newest",
}: {
currentPage: number;
totalPages: number;
basePath?: string;
activeTag?: string | null;
sort?: "newest" | "oldest";
} = $props();
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return `${basePath}/`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`;
if (!tagSlug) return `${basePath}/${query}`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
}
function pageHref(page: number): string {
@@ -28,7 +32,7 @@
const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}`
: "";
return `${basePath}${tagPart}/page/${page}/`;
return `${basePath}${tagPart}/page/${page}/${query}`;
}
const showPagination = $derived(totalPages > 1);
@@ -46,7 +50,7 @@
});
const baseShape =
"rounded-xs h-8 mx-[2px] flex justify-center items-center no-underline text-sm font-medium border transition-colors";
"rounded-full h-8 mx-[3px] flex justify-center items-center no-underline text-xs font-medium border transition-colors";
const inactiveColors =
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
const activeColors =
+22 -4
View File
@@ -50,6 +50,16 @@
const isPastEvent = $derived(
hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(),
);
// „Neu"-Badge: Beitrag jünger als 7 Tage (nach Erstellung). Nicht bei
// vergangenen Terminen.
const NEW_MS = 7 * 24 * 60 * 60 * 1000;
const isNew = $derived.by(() => {
if (isPastEvent || !post.created) return false;
const ts = new Date(post.created).getTime();
if (Number.isNaN(ts)) return false;
return Date.now() - ts < NEW_MS;
});
</script>
<Card
@@ -95,8 +105,16 @@
</span>
</div>
{/if}
{#if commentCount != null}
<div class="absolute top-2 left-2">
<div class="absolute top-2 left-2 flex flex-col items-start gap-1">
{#if isNew}
<span
class="inline-flex items-center gap-1 rounded-full bg-wald-600 px-2 py-1 text-[.65rem] font-semibold uppercase tracking-wide text-white shadow-sm"
>
<Icon icon="lucide:sparkles" class="size-3" />
Neu
</span>
{/if}
{#if commentCount != null}
<span
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-stein-700 shadow-sm"
aria-label={t(T.comments_count_aria, { count: commentCount })}
@@ -105,8 +123,8 @@
<Icon icon="lucide:message-circle" class="size-3" />
{commentCount}
</span>
</div>
{/if}
{/if}
</div>
</div>
<div class="flex-1 flex flex-col justify-start p-4">
<PostMeta
+8 -23
View File
@@ -4,6 +4,7 @@
import { goto } from "$app/navigation";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import SearchField from "$lib/ui/SearchField.svelte";
type SearchHit = {
type: "page" | "post";
@@ -161,29 +162,13 @@
out:slide={{ duration: 150 }}
>
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
<label class="relative block max-w-xl mx-auto">
<span class="sr-only">Suche</span>
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-stein-300">
<Icon icon="lucide:search" class="h-5 w-5" />
</span>
<input
bind:this={inputEl}
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-xs border border-stein-0/20 bg-stein-800/70 py-3 pl-10 pr-10 text-sm text-stein-0 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
aria-label="Eingabe leeren"
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-xs p-1 text-stein-300 hover:bg-stein-0/10 hover:text-stein-0"
>
<Icon icon="lucide:x" class="h-4 w-4" />
</button>
{/if}
</label>
<SearchField
bind:value={query}
bind:inputEl
tone="dark"
placeholder="Seiten und Beiträge durchsuchen…"
class="max-w-xl mx-auto"
/>
<div class="max-w-xl mx-auto mt-4 text-sm">
<p class="sr-only" role="status" aria-live="polite">
+116 -30
View File
@@ -15,6 +15,7 @@
import { marked } from "$lib/markdown-safe";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import QrModal from "$lib/components/QrModal.svelte";
@@ -50,6 +51,7 @@
* + selected day. */
let showPast = $state(false);
let activeTag = $state<string | null>(null);
let search = $state("");
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
@@ -153,11 +155,29 @@
});
const filteredItems = $derived.by(() => {
if (!activeTag) return items;
const needle = activeTag.toLowerCase();
return items.filter((it) =>
(it.tags ?? []).some((t) => t.toLowerCase() === needle),
);
let list = items;
if (activeTag) {
const needle = activeTag.toLowerCase();
list = list.filter((it) =>
(it.tags ?? []).some((t) => t.toLowerCase() === needle),
);
}
const q = search.trim().toLowerCase();
if (q) {
list = list.filter((it) => {
const hay = [
it.title,
locationText(it.location),
it.description,
...(it.tags ?? []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}
return list;
});
/** Map a day-key → events overlapping that day (multi-day events
@@ -217,6 +237,20 @@
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
// Lange Zukunfts-Liste zunächst auf die nächsten Tage begrenzen, Rest per
// „Weitere anzeigen" ausklappbar.
const INITIAL_DAY_GROUPS = 5;
let showAllFuture = $state(false);
const visibleFutureGroups = $derived(
showAllFuture
? futureEventsByDay
: futureEventsByDay.slice(0, INITIAL_DAY_GROUPS),
);
const hiddenFutureCount = $derived(
futureEvents.length -
visibleFutureGroups.reduce((n, g) => n + g.events.length, 0),
);
const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
// ── Calendar-grid derived state ──────────────────────────────────
@@ -339,6 +373,11 @@
selectedDay = null;
}
function goToToday() {
currentMonth = new Date();
selectedDay = null;
}
/** "Show me this in the calendar" — used from accordion bodies for
* events that may be in a different month than the current view.
* Scrolls back up to the widget since it now sits above the list. */
@@ -922,38 +961,69 @@
</h3>
{/if}
{#if allTags.length > 0}
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
mindestens ein Termin Tags hat — sonst Lärm. -->
<div
class="flex flex-wrap gap-1.5 items-center px-4 py-2 border-b border-stein-200 bg-stein-50"
<!-- Filterleiste: Suche + „Heute" -->
<div class="flex flex-wrap items-center gap-2 border-b border-stein-200 px-4 py-2">
<SearchField
bind:value={search}
placeholder="Termine durchsuchen …"
ariaLabel="Termine durchsuchen"
class="min-w-0 flex-1"
/>
<button
type="button"
onclick={goToToday}
class="chip inline-flex items-center gap-1.5 border-stein-300 text-stein-700 hover:bg-stein-100"
>
<span class="text-[11px] uppercase tracking-wide text-stein-500">
{t(T.calendar_filter_by_tag)}
</span>
<button
type="button"
onclick={() => (activeTag = null)}
class="chip {!activeTag
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={!activeTag}
<Icon icon="lucide:calendar-check" class="size-3.5" aria-hidden="true" />
Heute
</button>
</div>
{#if allTags.length > 0}
<!-- Tag-Filter chips, einklappbar (kann bei vielen Themen lang werden).
"Alle" = activeTag null. Aktiver Tag steht in der Summary, damit ein
gesetzter Filter auch zugeklappt sichtbar bleibt. -->
<details class="border-b border-stein-200 bg-stein-50 group/tags">
<summary
class="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2 text-[11px] uppercase tracking-wide text-stein-500 hover:bg-stein-100"
>
{t(T.calendar_all_tags)}
</button>
{#each allTags as tag}
<Icon
icon="lucide:chevron-right"
class="size-3.5 transition-transform group-open/tags:rotate-90"
aria-hidden="true"
/>
{t(T.calendar_filter_by_tag)}
{#if activeTag}
<span class="ml-1 rounded-full bg-himmel-700 px-2 py-0.5 text-[10px] normal-case tracking-normal text-himmel-50">
{activeTag}
</span>
{/if}
</summary>
<div class="flex flex-wrap items-center gap-1.5 px-4 pb-2 pt-1">
<button
type="button"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
class="chip {activeTag === tag
onclick={() => (activeTag = null)}
class="chip {!activeTag
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={activeTag === tag}
aria-pressed={!activeTag}
>
{tag}
{t(T.calendar_all_tags)}
</button>
{/each}
</div>
{#each allTags as tag}
<button
type="button"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
class="chip {activeTag === tag
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={activeTag === tag}
>
{tag}
</button>
{/each}
</div>
</details>
{/if}
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
@@ -1222,7 +1292,7 @@
</span>
</div>
<ul class="m-0! p-0! list-none">
{#each futureEventsByDay as group, gIdx}
{#each visibleFutureGroups as group, gIdx}
<li class="day-group">
<div
class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm"
@@ -1245,6 +1315,22 @@
</li>
{/each}
</ul>
{#if futureEventsByDay.length > INITIAL_DAY_GROUPS}
<button
type="button"
onclick={() => (showAllFuture = !showAllFuture)}
class="inline-flex w-full items-center justify-center gap-1.5 border-t border-stein-200 px-3 py-2 text-xs font-medium text-himmel-700 hover:bg-himmel-50"
>
<Icon
icon={showAllFuture ? "lucide:chevron-up" : "lucide:chevron-down"}
class="size-3.5"
aria-hidden="true"
/>
{showAllFuture
? "Weniger anzeigen"
: `Weitere ${hiddenFutureCount} Termine anzeigen`}
</button>
{/if}
{#if pastEvents.length > 0}
<details
class="border-t border-stein-200 bg-stein-50 group/past"
@@ -178,6 +178,9 @@
"bot": {
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2m16 0h2m-7-1v2m-6-2v2\"/></g>"
},
"circle-question-mark": {
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01\"/></g>"
},
"sliders-horizontal": {
"body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 5H3m9 14H3M14 3v4m2 10v4m5-9h-9m9 7h-5m5-14h-7m-6 5v4m0-2H3\"/>"
},
@@ -272,6 +275,9 @@
}
},
"aliases": {
"circle-help": {
"parent": "circle-question-mark"
},
"file-video": {
"parent": "file-play"
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
value = $bindable(""),
placeholder = "Suchen …",
tone = "light" as "light" | "dark",
ariaLabel = "Suche",
autofocus = false,
class: className = "",
inputEl = $bindable<HTMLInputElement | null>(null),
}: {
value?: string;
placeholder?: string;
tone?: "light" | "dark";
ariaLabel?: string;
autofocus?: boolean;
class?: string;
inputEl?: HTMLInputElement | null;
} = $props();
const toneClasses = $derived(
tone === "dark"
? "border-stein-0/20 bg-stein-800/70 text-stein-0 placeholder:text-stein-400 focus:border-wald-400"
: "border-stein-300 bg-white text-stein-800 placeholder:text-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-200",
);
function focusOnMount(node: HTMLInputElement, enabled: boolean) {
if (enabled) setTimeout(() => node.focus({ preventScroll: true }), 0);
}
</script>
<label class="relative block {className}">
<span class="sr-only">{ariaLabel}</span>
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-stein-400">
<Icon icon="lucide:search" class="size-4" aria-hidden="true" />
</span>
<input
bind:this={inputEl}
type="search"
bind:value
{placeholder}
aria-label={ariaLabel}
use:focusOnMount={autofocus}
class="w-full rounded-md border py-2 pl-8 pr-8 text-sm focus:outline-none {toneClasses}"
/>
{#if value}
<button
type="button"
onclick={() => (value = "")}
aria-label="Suche leeren"
class="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 {tone === 'dark'
? 'text-stein-400 hover:bg-stein-0/10 hover:text-stein-0'
: 'text-stein-400 hover:bg-stein-100 hover:text-stein-700'}"
>
<Icon icon="lucide:x" class="size-3.5" aria-hidden="true" />
</button>
{/if}
</label>
+3 -2
View File
@@ -3,9 +3,10 @@ import { loadPostsList } from '$lib/blog-utils';
import { warmPostCardImages } from '$lib/rusty-image.server';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ locals, fetch }) => {
export const load: PageServerLoad = async ({ locals, fetch, url }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ page: 1 });
const sort = url.searchParams.get('sort') === 'oldest' ? 'oldest' : 'newest';
const result = await loadPostsList({ page: 1, sort });
warmPostCardImages(
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
+1
View File
@@ -34,6 +34,7 @@
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
sort={data.sort}
/>
{/if}
</div>
+3 -2
View File
@@ -3,10 +3,11 @@ import { loadPostsList } from '$lib/blog-utils';
import { warmPostCardImages } from '$lib/rusty-image.server';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
export const load: PageServerLoad = async ({ params, locals, fetch, url }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ page });
const sort = url.searchParams.get('sort') === 'oldest' ? 'oldest' : 'newest';
const result = await loadPostsList({ page, sort });
warmPostCardImages(
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
@@ -34,6 +34,7 @@
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
sort={data.sort}
/>
{/if}
</div>
+3 -2
View File
@@ -3,9 +3,10 @@ import { loadPostsList } from '$lib/blog-utils';
import { warmPostCardImages } from '$lib/rusty-image.server';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
export const load: PageServerLoad = async ({ params, locals, fetch, url }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ tagSlug: params.tag, page: 1 });
const sort = url.searchParams.get('sort') === 'oldest' ? 'oldest' : 'newest';
const result = await loadPostsList({ tagSlug: params.tag, page: 1, sort });
warmPostCardImages(
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
+1
View File
@@ -34,6 +34,7 @@
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
sort={data.sort}
/>
{/if}
</div>
@@ -3,10 +3,11 @@ import { loadPostsList } from '$lib/blog-utils';
import { warmPostCardImages } from '$lib/rusty-image.server';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
export const load: PageServerLoad = async ({ params, locals, fetch, url }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ tagSlug: params.tag, page });
const sort = url.searchParams.get('sort') === 'oldest' ? 'oldest' : 'newest';
const result = await loadPostsList({ tagSlug: params.tag, page, sort });
warmPostCardImages(
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
@@ -34,6 +34,7 @@
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
sort={data.sort}
/>
{/if}
</div>