Blog-Übersicht: Featured-Hero, Neu-Badge, Org-Sektion, Sortierung, konsolidiertes Suchfeld
- 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:
+10
-1
@@ -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,
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,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 }>,
|
||||
|
||||
@@ -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({ 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,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 }>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user