import type { PostEntry } from "./cms"; import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug, getCalendarBySlug, getCalendarItemBySlug, getCalendarItems, } from "./cms"; import type { RowContentLayout } from "./block-types"; import type { CalendarItemData } from "./block-types"; import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image"; /** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */ export type TagMeta = { name: string; icon?: string; color?: string; }; /** Slug → TagMeta aus der Tag-API. Für Auflösung von post.postTag. */ export async function getTagsMap(): Promise> { const tags = await getTags(); const m = new Map(); for (const t of tags) { const slug = (t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? ""; if (!slug) continue; const name = (t as { name?: string }).name?.trim() ?? slug; const icon = (t as { icon?: string }).icon?.trim(); const color = (t as { color?: string }).color?.trim(); m.set(slug, { name, ...(icon ? { icon } : {}), ...(color ? { color } : {}), }); } return m; } export type ResolvedPostTag = { _slug: string; name: string; icon?: string; color?: string; }; /** Setzt post.postTag auf { _slug, name, icon?, color? }[]; Metadaten aus slugToMeta bzw. bereits aufgelösten Refs. */ export function resolvePostTagsInPost( post: PostEntry, slugToMeta: Map, ): void { const raw = post.postTag ?? []; if (!Array.isArray(raw)) return; (post as { postTag?: ResolvedPostTag[] }).postTag = raw.map((t) => { if (typeof t === "string") { const meta = slugToMeta.get(t); return { _slug: t, name: meta?.name ?? t, ...(meta?.icon ? { icon: meta.icon } : {}), ...(meta?.color ? { color: meta.color } : {}), }; } const o = t as { _slug?: string; name?: string; icon?: string; color?: string; }; const slug = o._slug ?? o?.name ?? ""; const meta = slug ? slugToMeta.get(slug) : undefined; const icon = (o.icon ?? meta?.icon)?.trim(); const color = (o.color ?? meta?.color)?.trim(); return { _slug: slug, name: o?.name ?? meta?.name ?? slug, ...(icon ? { icon } : {}), ...(color ? { color } : {}), }; }); } const POSTS_PER_PAGE = 10; export function getPostsPerPage(): number { return POSTS_PER_PAGE; } /** Filtert Posts mit hideFromListing: true aus Übersichten heraus. */ export function filterHiddenPosts(posts: PostEntry[]): PostEntry[] { return posts.filter( (p) => !(p as PostEntry & { hideFromListing?: boolean }).hideFromListing, ); } /** Sortiert Posts nach Datum (neueste zuerst). */ export function sortPostsByDate(posts: PostEntry[]): PostEntry[] { return [...posts].sort((a, b) => { const da = a.created ?? ""; const db = b.created ?? ""; if (!da) return 1; if (!db) return -1; return new Date(db).getTime() - new Date(da).getTime(); }); } /** Filtert Posts nach Tag-Slug (post.postTag enthält den Slug). */ export function filterPostsByTag( posts: PostEntry[], tagSlug: string | null, ): PostEntry[] { if (!tagSlug) return posts; return posts.filter((p) => { const tags = p.postTag ?? []; return tags.some((t) => { const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug; return slug === tagSlug; }); }); } export type PostEventLocation = { text?: string; lat?: number; lng?: number; }; export type PostEventInfo = { isEvent: boolean; eventDate: string | null; eventLocation: PostEventLocation | null; }; /** * Liest event-Daten aus dem aufgelösten calendarItem-Reference des Posts. * Voraussetzung: post mit resolve=["all"] oder explizit "calendarItem" geladen, * sodass `post.calendarItem` als Objekt mit terminZeit/location vorliegt. */ export function getPostEventInfo( post: PostEntry | null | undefined, ): PostEventInfo { const ci = (post as { calendarItem?: unknown } | null | undefined)?.calendarItem; if (!ci || typeof ci !== "object") { return { isEvent: false, eventDate: null, eventLocation: null }; } const obj = ci as { terminZeit?: string; location?: PostEventLocation; }; if (!obj.terminZeit) { return { isEvent: false, eventDate: null, eventLocation: null }; } return { isEvent: true, eventDate: obj.terminZeit, eventLocation: obj.location ?? null, }; } /** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */ export function getPostImageUrl( postImage: PostEntry["postImage"], ): string | null { return getPostImageField(postImage)?.url ?? null; } /** * URL + Focal + Alt aus post.postImage. Unterstützt RustyCMS-image-Typ * und legacy file.url / fields.file.url. */ export function getPostImageField( postImage: PostEntry["postImage"], ): CmsImageField | null { if (!postImage) return null; if (typeof postImage === "string") return extractCmsImageField(postImage); const field = extractCmsImageField(postImage); if (field) return field; const obj = postImage as { fields?: { file?: { url?: string } }; }; const legacy = obj?.fields?.file?.url; if (typeof legacy === "string" && legacy.trim()) { return { url: legacy.startsWith("//") ? `https:${legacy}` : legacy }; } return null; } /** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */ export function filterPostsByTagSlugs( posts: PostEntry[], tagSlugs: string[], ): PostEntry[] { if (!tagSlugs?.length) return posts; return posts.filter((p) => { const tags = p.postTag ?? []; return tags.some((t) => { const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug; return tagSlugs.includes(slug ?? ""); }); }); } /** Filtert Posts: entfernt solche, die mindestens einen der Tag-Slugs haben. */ export function excludePostsByTagSlugs( posts: PostEntry[], tagSlugs: string[], ): PostEntry[] { if (!tagSlugs?.length) return posts; return posts.filter((p) => { const tags = p.postTag ?? []; return !tags.some((t) => { const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug; return tagSlugs.includes(slug ?? ""); }); }); } export function getTotalPages(count: number, perPage: number): number { return Math.max(1, Math.ceil(count / perPage)); } export function paginate(items: T[], page: number, perPage: number): T[] { const start = (page - 1) * perPage; return items.slice(start, start + perPage); } export type PostSearchIndexEntry = { slug: string; headline: string; excerpt: string; subheadline: string; tags: string[]; image: string | null; created: string | null; isEvent: boolean; eventDate: string | null; }; type PostWithImageIndex = PostEntry & { _resolvedImageUrl?: string; }; export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] { return posts.map((p) => { const ev = getPostEventInfo(p); return { slug: p.slug ?? p._slug ?? "", headline: p.headline ?? "", excerpt: p.excerpt ?? "", subheadline: p.subheadline ?? "", tags: (p.postTag ?? []) .map((t) => typeof t === "string" ? t : (t as { name?: string; _slug?: string }).name ?? (t as { _slug?: string })._slug ?? "", ) .filter(Boolean), image: (p as PostWithImageIndex)._resolvedImageUrl ?? null, created: p.created ?? null, isEvent: ev.isEvent, eventDate: ev.eventDate, }; }); } export type LoadPostsResult = { posts: PostEntry[]; tags: Awaited>; activeTag: string | null; currentPage: number; totalPages: number; totalPosts: number; upcomingEvents: PostEntry[]; searchIndex: PostSearchIndexEntry[]; tagName: string | null; cmsError: string | null; }; /** Gemeinsame Ladefunktion für /posts, /posts/page/[n], /posts/tag/[t], /posts/tag/[t]/page/[n]. */ export async function loadPostsList(opts: { tagSlug?: string | null; page?: number; }): Promise { const tagSlug = opts.tagSlug ?? null; const requestedPage = Math.max(1, opts.page ?? 1); const perPage = getPostsPerPage(); let posts: PostEntry[] = []; let tags: Awaited> = []; let cmsError: string | null = null; try { // Sparse fieldset für die Listen-Ansicht: Body/Rows brauchen wir hier // nicht, nur Karten-Felder + image + tag-Metadaten. _depth=2 reicht // (post → postImage → img-Sidecar; post → postTag → name/icon/color). [posts, tags] = await Promise.all([ getPosts({ resolve: ["postImage", "postTag", "calendarItem"], depth: 2, fields: [ "_slug", "slug", "headline", "linkName", "subheadline", "excerpt", "created", "calendarItem", "hideFromListing", "postImage.*", "postTag.name", "postTag.icon", "postTag.color", "postTag._slug", ], }), getTags(), ]); posts = filterHiddenPosts(sortPostsByDate(posts)); const tagsMap = await getTagsMap(); for (const p of posts) resolvePostTagsInPost(p, tagsMap); for (const post of posts) { const field = getPostImageField(post.postImage); if (field) { try { const url = await ensureTransformedImage(field.url, { width: 400, height: 267, fit: "cover", format: "webp", focalX: field.focal?.x, focalY: field.focal?.y, }); const p = post as PostEntry & { _resolvedImageUrl?: string; _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null; }; p._resolvedImageUrl = url; p._rawImageUrl = field.url; p._imageFocal = field.focal ?? null; } catch { /* image optional */ } } } } catch (e) { cmsError = e instanceof Error ? e.message : String(e); } const filtered = filterPostsByTag(posts, tagSlug); const totalPages = getTotalPages(filtered.length, perPage); const pageNum = Math.min(requestedPage, totalPages); const pagePosts = paginate(filtered, pageNum, perPage); const tagName = tagSlug ? (tags.find((t) => ((t as { _slug?: string })._slug ?? "") === tagSlug)?.name ?? tagSlug) : null; const upcomingEvents = selectUpcomingEvents(posts); const searchIndex = buildPostSearchIndex(posts); return { posts: pagePosts, tags, activeTag: tagSlug, currentPage: pageNum, totalPages, totalPosts: filtered.length, upcomingEvents, searchIndex, tagName, cmsError, }; } export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] { const now = Date.now(); type Scored = { post: PostEntry; ts: number }; const scored: Scored[] = []; for (const p of posts) { const ev = getPostEventInfo(p); if (!ev.isEvent || !ev.eventDate) continue; const ts = new Date(ev.eventDate).getTime(); if (ts >= now) scored.push({ post: p, ts }); } return scored .sort((a, b) => a.ts - b.ts) .slice(0, max) .map((s) => s.post); } const POST_BASE = "/post"; /** Post-URL: slug ist die URL; führender Slash entfernt. */ export function postHref(post: PostEntry): string { const slug = post.slug ?? post._slug ?? ""; const norm = (slug ?? "").replace(/^\//, "").trim(); return norm ? `${POST_BASE}/${encodeURIComponent(norm)}/` : `${POST_BASE}/`; } export function formatPostDate(value: string | undefined): string { if (!value) return ""; try { const d = new Date(value); return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("de-DE", { year: "numeric", month: "short", day: "numeric", }); } catch { return ""; } } /** Prüft, ob ein Block ein Post-Overview-Block ist (mit _type "post_overview"). */ function isPostOverviewBlock(item: unknown): item is { _type?: string; allPosts?: boolean; posts?: unknown[]; filterByTag?: unknown[]; excludeTag?: unknown[]; numberItems?: number; postsResolved?: unknown[]; } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "post_overview" ); } /** * Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved. * Nutzt die globale Tag-API (getTagsMap()) für Slug → TagMeta. * Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug]+page.server.ts). */ export async function resolvePostOverviewBlocks( layout: RowContentLayout | null | undefined, ): Promise> { const tagsMap = await getTagsMap(); if (!layout) return tagsMap; const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; let allPosts: PostEntry[] | null = null; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isPostOverviewBlock(item)) continue; const rawFilter = item.filterByTag ?? []; const tagSlugs = Array.isArray(rawFilter) ? rawFilter .map((t) => typeof t === "string" ? t : ((t as { _slug?: string })?._slug ?? ""), ) .filter(Boolean) : []; const rawExclude = item.excludeTag ?? []; const excludeSlugs = Array.isArray(rawExclude) ? rawExclude .map((t) => typeof t === "string" ? t : ((t as { _slug?: string })?._slug ?? ""), ) .filter(Boolean) : []; const hasExplicitPosts = Array.isArray(item.posts) && item.posts.length > 0; const useAllPosts = item.allPosts || (tagSlugs.length > 0 && !hasExplicitPosts); if (useAllPosts) { if (allPosts === null) allPosts = await getPosts({ _sort: "created", _order: "desc", resolve: "all", }); let list = filterHiddenPosts(sortPostsByDate(allPosts)); if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs); if (excludeSlugs.length) list = excludePostsByTagSlugs(list, excludeSlugs); const limit = typeof item.numberItems === "number" ? item.numberItems : 9999; item.postsResolved = list.slice(0, limit); } else if (hasExplicitPosts && Array.isArray(item.posts)) { const first = item.posts[0]; const isResolved = typeof first === "object" && first !== null && "_slug" in first && ("headline" in first || "linkName" in first); if (isResolved) { item.postsResolved = item.posts as PostEntry[]; } else { const slugs = item.posts .map((p) => typeof p === "string" ? p : ((p as { _slug?: string })?._slug ?? ""), ) .filter(Boolean); const limit = typeof item.numberItems === "number" ? item.numberItems : 9999; const resolved: PostEntry[] = []; for (const slug of slugs.slice(0, limit)) { const post = await getPostBySlug(slug, { locale: "de", resolve: ["all"], }); if (post) resolved.push(post); } item.postsResolved = resolved; } } if (Array.isArray(item.postsResolved)) { const posts = item.postsResolved as PostEntry[]; if (posts.length > 0) { for (const post of posts) resolvePostTagsInPost(post, tagsMap); } for (const post of item.postsResolved as PostEntry[]) { const field = getPostImageField(post.postImage); if (field) { try { const url = await ensureTransformedImage(field.url, { width: 400, height: 267, fit: "cover", format: "webp", focalX: field.focal?.x, focalY: field.focal?.y, }); const p = post as PostEntry & { _resolvedImageUrl?: string; _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null; }; p._resolvedImageUrl = url; p._rawImageUrl = field.url; p._imageFocal = field.focal ?? null; } catch { // Bild optional } } } } } } return tagsMap; } function isCalendarBlock( item: unknown, ): item is { _type?: string; _slug?: string; items?: unknown[] } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "calendar" ); } /** * Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items * mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link). */ export async function resolveCalendarBlocks( layout: RowContentLayout, ): Promise { const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isCalendarBlock(item)) continue; const slug = item._slug; if (!slug) continue; const rawItems = item.items ?? []; const first = rawItems[0]; const alreadyResolved = typeof first === "object" && first !== null && "terminZeit" in first && "title" in first; if (alreadyResolved) continue; const calendar = await getCalendarBySlug(slug, { locale: "de", resolve: "items", }); const raw = calendar?.items ?? rawItems; const resolved: CalendarItemData[] = []; for (const x of raw) { if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) { resolved.push(x as CalendarItemData); } else { const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug; if (islug) { const entry = await getCalendarItemBySlug(islug, { locale: "de" }); if (entry) resolved.push(entry); } } } (item as { items?: CalendarItemData[] }).items = resolved; } } } function isDeadlineBannerBlock( item: unknown, ): item is { _type?: string; mode?: string; items?: unknown[]; } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "deadline_banner" ); } /** * Löst deadline_banner-Blöcke im auto-Modus auf: Lädt alle calendar_items * global und setzt sie als Pool in block.items, sofern dort noch keine * aufgelösten Einträge liegen. Die Komponente wählt daraus den nächsten * zukünftigen Termin. */ export async function resolveDeadlineBannerBlocks( layout: RowContentLayout, ): Promise { const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; let pool: CalendarItemData[] | null = null; const loadPool = async (): Promise => { if (pool === null) { const items = await getCalendarItems({ locale: "de" }); pool = items as unknown as CalendarItemData[]; } return pool; }; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isDeadlineBannerBlock(item)) continue; if (item.mode !== "auto") continue; const existing = item.items ?? []; const first = existing[0]; const alreadyResolved = typeof first === "object" && first !== null && "terminZeit" in first && "title" in first; if (alreadyResolved) continue; (item as { items?: CalendarItemData[] }).items = await loadPool(); } } } function isSearchableTextBlock( item: unknown, ): item is { _type?: string; textFragments?: unknown[] } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "searchable_text" ); } /** Prüft, ob ein Fragment bereits aufgelöst ist (hat title oder text). */ function isResolvedFragment(f: unknown): boolean { if (typeof f !== "object" || f === null) return false; const o = f as { title?: string; text?: string }; return "title" in o || "text" in o; } /** * Normalisiert tags eines Fragments zu Anzeigenamen (string[]). * Nutzt slugToMeta für Slug → Anzeigename; Objekte mit name/_slug werden unterstützt. */ function resolveFragmentTags( tagsRaw: unknown[] | undefined, slugToMeta: Map, ): string[] { if (!Array.isArray(tagsRaw)) return []; return tagsRaw .map((t) => { if (typeof t === "string") return slugToMeta.get(t)?.name ?? t; const o = t as { _slug?: string; name?: string }; const slug = o._slug ?? ""; return (o.name ?? slugToMeta.get(slug)?.name ?? slug).trim(); }) .filter(Boolean); } /** * Lädt für alle searchable_text-Blöcke im Layout die textFragments nach * (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und * löst Tag-Slugs/Refs in Anzeigenamen auf. * @param slugToMeta Optionale Map Slug → TagMeta (z. B. von resolvePostOverviewBlocks); sonst getTagsMap(). */ export async function resolveSearchableTextBlocks( layout: RowContentLayout | null | undefined, slugToMeta?: Map, ): Promise { if (!layout) return; const tagsMap = slugToMeta ?? (await getTagsMap()); const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isSearchableTextBlock(item)) continue; const raw = item.textFragments ?? []; if (!Array.isArray(raw) || raw.length === 0) continue; const needsResolve = raw.some((f) => !isResolvedFragment(f)); if (!needsResolve) continue; const resolved: unknown[] = []; for (const f of raw) { const slug = typeof f === "string" ? f : ((f as { _slug?: string })?._slug ?? ""); if (!slug) { if (isResolvedFragment(f)) resolved.push(f); continue; } const frag = await getTextFragmentBySlug(slug, { locale: "de" }); if (frag) { const fragObj = frag as { tags?: unknown[] }; const tagNames = resolveFragmentTags(fragObj.tags, tagsMap); resolved.push({ ...fragObj, tags: tagNames }); } } (item as { textFragments?: unknown[] }).textFragments = resolved; } } }