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 = {
|
export type LoadPostsResult = {
|
||||||
posts: PostEntry[];
|
posts: PostEntry[];
|
||||||
tags: Awaited<ReturnType<typeof getTags>>;
|
tags: Awaited<ReturnType<typeof getTags>>;
|
||||||
@@ -277,6 +279,7 @@ export type LoadPostsResult = {
|
|||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalPosts: number;
|
totalPosts: number;
|
||||||
|
sort: PostSortOrder;
|
||||||
upcomingEvents: PostEntry[];
|
upcomingEvents: PostEntry[];
|
||||||
searchIndex: PostSearchIndexEntry[];
|
searchIndex: PostSearchIndexEntry[];
|
||||||
tagName: string | null;
|
tagName: string | null;
|
||||||
@@ -287,9 +290,11 @@ export type LoadPostsResult = {
|
|||||||
export async function loadPostsList(opts: {
|
export async function loadPostsList(opts: {
|
||||||
tagSlug?: string | null;
|
tagSlug?: string | null;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
sort?: PostSortOrder;
|
||||||
}): Promise<LoadPostsResult> {
|
}): Promise<LoadPostsResult> {
|
||||||
const tagSlug = opts.tagSlug ?? null;
|
const tagSlug = opts.tagSlug ?? null;
|
||||||
const requestedPage = Math.max(1, opts.page ?? 1);
|
const requestedPage = Math.max(1, opts.page ?? 1);
|
||||||
|
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
|
||||||
const perPage = getPostsPerPage();
|
const perPage = getPostsPerPage();
|
||||||
|
|
||||||
let posts: PostEntry[] = [];
|
let posts: PostEntry[] = [];
|
||||||
@@ -351,7 +356,10 @@ export async function loadPostsList(opts: {
|
|||||||
cmsError = e instanceof Error ? e.message : String(e);
|
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 totalPages = getTotalPages(filtered.length, perPage);
|
||||||
const pageNum = Math.min(requestedPage, totalPages);
|
const pageNum = Math.min(requestedPage, totalPages);
|
||||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||||
@@ -370,6 +378,7 @@ export async function loadPostsList(opts: {
|
|||||||
currentPage: pageNum,
|
currentPage: pageNum,
|
||||||
totalPages,
|
totalPages,
|
||||||
totalPosts: filtered.length,
|
totalPosts: filtered.length,
|
||||||
|
sort,
|
||||||
upcomingEvents,
|
upcomingEvents,
|
||||||
searchIndex,
|
searchIndex,
|
||||||
tagName,
|
tagName,
|
||||||
|
|||||||
@@ -5,8 +5,23 @@
|
|||||||
import EventBadges from "./EventBadges.svelte";
|
import EventBadges from "./EventBadges.svelte";
|
||||||
import Tag from "./Tag.svelte";
|
import Tag from "./Tag.svelte";
|
||||||
import Pagination from "./Pagination.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 type { PostEntry } from "$lib/cms";
|
||||||
import { postHref, getPostEventInfo } from "$lib/blog-utils";
|
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 { t as tStatic, T } from "$lib/translations";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +60,7 @@
|
|||||||
translations = {},
|
translations = {},
|
||||||
upcomingEvents = [],
|
upcomingEvents = [],
|
||||||
searchIndex = null,
|
searchIndex = null,
|
||||||
|
sort = "newest",
|
||||||
}: {
|
}: {
|
||||||
posts: PostEntry[];
|
posts: PostEntry[];
|
||||||
tags: TagEntry[];
|
tags: TagEntry[];
|
||||||
@@ -56,10 +72,79 @@
|
|||||||
translations?: Translations | null;
|
translations?: Translations | null;
|
||||||
upcomingEvents?: PostEntry[];
|
upcomingEvents?: PostEntry[];
|
||||||
searchIndex?: SearchIndexEntry[] | null;
|
searchIndex?: SearchIndexEntry[] | null;
|
||||||
|
sort?: PostSortOrder;
|
||||||
} = $props();
|
} = $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 query = $state("");
|
||||||
let year = $state<string>("");
|
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 normalizedQuery = $derived(query.trim().toLowerCase());
|
||||||
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
||||||
|
|
||||||
@@ -128,30 +213,12 @@
|
|||||||
<div class="blog-overview">
|
<div class="blog-overview">
|
||||||
{#if searchIndex}
|
{#if searchIndex}
|
||||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<label class="relative block w-full sm:max-w-sm">
|
<SearchField
|
||||||
<span class="sr-only">{t(T.blog_search_label)}</span>
|
bind:value={query}
|
||||||
<span
|
placeholder={t(T.blog_search_placeholder)}
|
||||||
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
|
ariaLabel={t(T.blog_search_label)}
|
||||||
>
|
class="w-full sm:max-w-sm"
|
||||||
<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>
|
|
||||||
{#if availableYears.length > 0}
|
{#if availableYears.length > 0}
|
||||||
<label class="relative inline-flex items-center">
|
<label class="relative inline-flex items-center">
|
||||||
<span class="sr-only">{t(T.blog_year_label)}</span>
|
<span class="sr-only">{t(T.blog_year_label)}</span>
|
||||||
@@ -162,7 +229,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
bind:value={year}
|
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>
|
<option value="">{t(T.blog_year_all)}</option>
|
||||||
{#each availableYears as y}
|
{#each availableYears as y}
|
||||||
@@ -180,11 +247,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isFiltering && upcomingEvents.length > 0}
|
{#if !isFiltering && upcomingEvents.length > 0}
|
||||||
<section
|
<Card variant="callout" class="not-prose mb-6">
|
||||||
class="not-prose mb-6 rounded-xs border border-stein-200 bg-stein-50 p-4"
|
|
||||||
>
|
|
||||||
<h2
|
<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" />
|
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
|
||||||
{t(T.blog_upcoming_events)}
|
{t(T.blog_upcoming_events)}
|
||||||
@@ -206,7 +271,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isFiltering}
|
{#if isFiltering}
|
||||||
@@ -233,9 +298,10 @@
|
|||||||
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{#each filteredResults as hit}
|
{#each filteredResults as hit}
|
||||||
<a
|
<Card
|
||||||
|
variant="tile"
|
||||||
|
layout="post"
|
||||||
href={searchResultHref(hit.slug)}
|
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}
|
{#if hit.image}
|
||||||
<div
|
<div
|
||||||
@@ -259,7 +325,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -270,7 +336,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#if tags.length > 0}
|
{#if topicTags.length > 0}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-2"
|
class="flex flex-wrap gap-2"
|
||||||
@@ -283,7 +349,7 @@
|
|||||||
variant={activeTag ? "blue" : "green"}
|
variant={activeTag ? "blue" : "green"}
|
||||||
active={!activeTag}
|
active={!activeTag}
|
||||||
/>
|
/>
|
||||||
{#each tags as tag}
|
{#each visibleTags as tag}
|
||||||
{@const slug = tag._slug ?? ""}
|
{@const slug = tag._slug ?? ""}
|
||||||
<Tag
|
<Tag
|
||||||
label={tag.name}
|
label={tag.name}
|
||||||
@@ -296,14 +362,161 @@
|
|||||||
: tag.color?.trim() || null}
|
: tag.color?.trim() || null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<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
|
<PostCard
|
||||||
{post}
|
{post}
|
||||||
href={postHref(post)}
|
href={postHref(post)}
|
||||||
@@ -314,36 +527,42 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
{#if currentPage === 1 && !activeTag}
|
||||||
<Pagination {currentPage} {totalPages} {basePath} {activeTag} />
|
<div
|
||||||
<div class="grow"></div>
|
class="not-prose my-6 rounded-sm bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
|
||||||
<div class="bg-white rounded-xs font-light">
|
>
|
||||||
<div class="inline-flex text-xs p-2">
|
<NewsletterInlineFormComponent />
|
||||||
{#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>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-stein-600">
|
<div class="mt-5">
|
||||||
<a
|
<a
|
||||||
href="/posts/rss.xml"
|
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)}
|
{t(T.blog_rss_subscribe)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,16 +11,20 @@
|
|||||||
totalPages = 1,
|
totalPages = 1,
|
||||||
basePath = "/posts",
|
basePath = "/posts",
|
||||||
activeTag = null,
|
activeTag = null,
|
||||||
|
sort = "newest",
|
||||||
}: {
|
}: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
activeTag?: string | null;
|
activeTag?: string | null;
|
||||||
|
sort?: "newest" | "oldest";
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
|
||||||
|
|
||||||
function tagHref(tagSlug: string | null): string {
|
function tagHref(tagSlug: string | null): string {
|
||||||
if (!tagSlug) return `${basePath}/`;
|
if (!tagSlug) return `${basePath}/${query}`;
|
||||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`;
|
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHref(page: number): string {
|
function pageHref(page: number): string {
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
const tagPart = activeTag
|
const tagPart = activeTag
|
||||||
? `/tag/${encodeURIComponent(activeTag)}`
|
? `/tag/${encodeURIComponent(activeTag)}`
|
||||||
: "";
|
: "";
|
||||||
return `${basePath}${tagPart}/page/${page}/`;
|
return `${basePath}${tagPart}/page/${page}/${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showPagination = $derived(totalPages > 1);
|
const showPagination = $derived(totalPages > 1);
|
||||||
@@ -46,7 +50,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const baseShape =
|
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 =
|
const inactiveColors =
|
||||||
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
|
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
|
||||||
const activeColors =
|
const activeColors =
|
||||||
|
|||||||
@@ -50,6 +50,16 @@
|
|||||||
const isPastEvent = $derived(
|
const isPastEvent = $derived(
|
||||||
hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(),
|
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>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -95,8 +105,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if commentCount != null}
|
<div class="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||||
<div class="absolute top-2 left-2">
|
{#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
|
<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"
|
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 })}
|
aria-label={t(T.comments_count_aria, { count: commentCount })}
|
||||||
@@ -105,8 +123,8 @@
|
|||||||
<Icon icon="lucide:message-circle" class="size-3" />
|
<Icon icon="lucide:message-circle" class="size-3" />
|
||||||
{commentCount}
|
{commentCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col justify-start p-4">
|
<div class="flex-1 flex flex-col justify-start p-4">
|
||||||
<PostMeta
|
<PostMeta
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
|
import SearchField from "$lib/ui/SearchField.svelte";
|
||||||
|
|
||||||
type SearchHit = {
|
type SearchHit = {
|
||||||
type: "page" | "post";
|
type: "page" | "post";
|
||||||
@@ -161,29 +162,13 @@
|
|||||||
out:slide={{ duration: 150 }}
|
out:slide={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
||||||
<label class="relative block max-w-xl mx-auto">
|
<SearchField
|
||||||
<span class="sr-only">Suche</span>
|
bind:value={query}
|
||||||
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-stein-300">
|
bind:inputEl
|
||||||
<Icon icon="lucide:search" class="h-5 w-5" />
|
tone="dark"
|
||||||
</span>
|
placeholder="Seiten und Beiträge durchsuchen…"
|
||||||
<input
|
class="max-w-xl mx-auto"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="max-w-xl mx-auto mt-4 text-sm">
|
<div class="max-w-xl mx-auto mt-4 text-sm">
|
||||||
<p class="sr-only" role="status" aria-live="polite">
|
<p class="sr-only" role="status" aria-live="polite">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { marked } from "$lib/markdown-safe";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
import SearchField from "$lib/ui/SearchField.svelte";
|
||||||
import ImageModal from "$lib/components/ImageModal.svelte";
|
import ImageModal from "$lib/components/ImageModal.svelte";
|
||||||
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||||
import QrModal from "$lib/components/QrModal.svelte";
|
import QrModal from "$lib/components/QrModal.svelte";
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
* + selected day. */
|
* + selected day. */
|
||||||
let showPast = $state(false);
|
let showPast = $state(false);
|
||||||
let activeTag = $state<string | null>(null);
|
let activeTag = $state<string | null>(null);
|
||||||
|
let search = $state("");
|
||||||
let currentMonth = $state(new Date());
|
let currentMonth = $state(new Date());
|
||||||
let selectedDay = $state<string | null>(null);
|
let selectedDay = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -153,11 +155,29 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredItems = $derived.by(() => {
|
const filteredItems = $derived.by(() => {
|
||||||
if (!activeTag) return items;
|
let list = items;
|
||||||
const needle = activeTag.toLowerCase();
|
if (activeTag) {
|
||||||
return items.filter((it) =>
|
const needle = activeTag.toLowerCase();
|
||||||
(it.tags ?? []).some((t) => t.toLowerCase() === needle),
|
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
|
/** Map a day-key → events overlapping that day (multi-day events
|
||||||
@@ -217,6 +237,20 @@
|
|||||||
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
|
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)));
|
const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
|
||||||
|
|
||||||
// ── Calendar-grid derived state ──────────────────────────────────
|
// ── Calendar-grid derived state ──────────────────────────────────
|
||||||
@@ -339,6 +373,11 @@
|
|||||||
selectedDay = null;
|
selectedDay = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
currentMonth = new Date();
|
||||||
|
selectedDay = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** "Show me this in the calendar" — used from accordion bodies for
|
/** "Show me this in the calendar" — used from accordion bodies for
|
||||||
* events that may be in a different month than the current view.
|
* 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. */
|
* Scrolls back up to the widget since it now sits above the list. */
|
||||||
@@ -922,38 +961,69 @@
|
|||||||
</h3>
|
</h3>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if allTags.length > 0}
|
<!-- Filterleiste: Suche + „Heute" -->
|
||||||
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
|
<div class="flex flex-wrap items-center gap-2 border-b border-stein-200 px-4 py-2">
|
||||||
mindestens ein Termin Tags hat — sonst Lärm. -->
|
<SearchField
|
||||||
<div
|
bind:value={search}
|
||||||
class="flex flex-wrap gap-1.5 items-center px-4 py-2 border-b border-stein-200 bg-stein-50"
|
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">
|
<Icon icon="lucide:calendar-check" class="size-3.5" aria-hidden="true" />
|
||||||
{t(T.calendar_filter_by_tag)}
|
Heute
|
||||||
</span>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onclick={() => (activeTag = null)}
|
{#if allTags.length > 0}
|
||||||
class="chip {!activeTag
|
<!-- Tag-Filter chips, einklappbar (kann bei vielen Themen lang werden).
|
||||||
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
"Alle" = activeTag null. Aktiver Tag steht in der Summary, damit ein
|
||||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
gesetzter Filter auch zugeklappt sichtbar bleibt. -->
|
||||||
aria-pressed={!activeTag}
|
<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)}
|
<Icon
|
||||||
</button>
|
icon="lucide:chevron-right"
|
||||||
{#each allTags as tag}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
onclick={() => (activeTag = null)}
|
||||||
class="chip {activeTag === tag
|
class="chip {!activeTag
|
||||||
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
||||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||||
aria-pressed={activeTag === tag}
|
aria-pressed={!activeTag}
|
||||||
>
|
>
|
||||||
{tag}
|
{t(T.calendar_all_tags)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{#each allTags as tag}
|
||||||
</div>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
|
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
|
||||||
@@ -1222,7 +1292,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="m-0! p-0! list-none">
|
<ul class="m-0! p-0! list-none">
|
||||||
{#each futureEventsByDay as group, gIdx}
|
{#each visibleFutureGroups as group, gIdx}
|
||||||
<li class="day-group">
|
<li class="day-group">
|
||||||
<div
|
<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"
|
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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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}
|
{#if pastEvents.length > 0}
|
||||||
<details
|
<details
|
||||||
class="border-t border-stein-200 bg-stein-50 group/past"
|
class="border-t border-stein-200 bg-stein-50 group/past"
|
||||||
|
|||||||
@@ -178,6 +178,9 @@
|
|||||||
"bot": {
|
"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>"
|
"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": {
|
"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\"/>"
|
"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": {
|
"aliases": {
|
||||||
|
"circle-help": {
|
||||||
|
"parent": "circle-question-mark"
|
||||||
|
},
|
||||||
"file-video": {
|
"file-video": {
|
||||||
"parent": "file-play"
|
"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 { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
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 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(
|
warmPostCardImages(
|
||||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
upcomingEvents={data.upcomingEvents}
|
upcomingEvents={data.upcomingEvents}
|
||||||
searchIndex={data.searchIndex}
|
searchIndex={data.searchIndex}
|
||||||
|
sort={data.sort}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { loadPostsList } from '$lib/blog-utils';
|
|||||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
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 translations = locals.translations ?? {};
|
||||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
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(
|
warmPostCardImages(
|
||||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
upcomingEvents={data.upcomingEvents}
|
upcomingEvents={data.upcomingEvents}
|
||||||
searchIndex={data.searchIndex}
|
searchIndex={data.searchIndex}
|
||||||
|
sort={data.sort}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { loadPostsList } from '$lib/blog-utils';
|
|||||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
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 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(
|
warmPostCardImages(
|
||||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
upcomingEvents={data.upcomingEvents}
|
upcomingEvents={data.upcomingEvents}
|
||||||
searchIndex={data.searchIndex}
|
searchIndex={data.searchIndex}
|
||||||
|
sort={data.sort}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { loadPostsList } from '$lib/blog-utils';
|
|||||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
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 translations = locals.translations ?? {};
|
||||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
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(
|
warmPostCardImages(
|
||||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
upcomingEvents={data.upcomingEvents}
|
upcomingEvents={data.upcomingEvents}
|
||||||
searchIndex={data.searchIndex}
|
searchIndex={data.searchIndex}
|
||||||
|
sort={data.sort}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user