diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index c9efcd6..6e13d78 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -270,6 +270,8 @@ export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] }); } +export type PostSortOrder = "newest" | "oldest"; + export type LoadPostsResult = { posts: PostEntry[]; tags: Awaited>; @@ -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 { 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, diff --git a/src/lib/components/BlogOverview.svelte b/src/lib/components/BlogOverview.svelte index f2580e2..b551987 100644 --- a/src/lib/components/BlogOverview.svelte +++ b/src/lib/components/BlogOverview.svelte @@ -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(""); + + // 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 @@
{#if searchIndex}
- + {#if availableYears.length > 0} +

diff --git a/src/lib/components/blocks/CalendarBlock.svelte b/src/lib/components/blocks/CalendarBlock.svelte index 4daa39b..023d48e 100644 --- a/src/lib/components/blocks/CalendarBlock.svelte +++ b/src/lib/components/blocks/CalendarBlock.svelte @@ -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(null); + let search = $state(""); let currentMonth = $state(new Date()); let selectedDay = $state(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 @@ {/if} - {#if allTags.length > 0} - -

+
+ + +
+ + {#if allTags.length > 0} + +
+ - {t(T.calendar_all_tags)} - - {#each allTags as tag} + +
- {/each} -
+ {#each allTags as tag} + + {/each} +
+ {/if}