892afc2d9f
Replace direct post.isEvent/eventDate/eventLocation reads with getPostEventInfo() helper that derives info from the resolved calendarItem reference. Loaders now resolve "calendarItem" and request that field instead of legacy flat properties. Also regenerate iconify mdi subset (map-marker-outline, download, google, calendar-search, refresh). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
756 lines
22 KiB
TypeScript
756 lines
22 KiB
TypeScript
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<Map<string, TagMeta>> {
|
|
const tags = await getTags();
|
|
const m = new Map<string, TagMeta>();
|
|
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<string, TagMeta>,
|
|
): 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<T>(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<ReturnType<typeof getTags>>;
|
|
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<LoadPostsResult> {
|
|
const tagSlug = opts.tagSlug ?? null;
|
|
const requestedPage = Math.max(1, opts.page ?? 1);
|
|
const perPage = getPostsPerPage();
|
|
|
|
let posts: PostEntry[] = [];
|
|
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
|
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<Map<string, TagMeta>> {
|
|
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<void> {
|
|
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<void> {
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
let pool: CalendarItemData[] | null = null;
|
|
const loadPool = async (): Promise<CalendarItemData[]> => {
|
|
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, TagMeta>,
|
|
): 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<string, TagMeta>,
|
|
): Promise<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|