Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
import type { PostEntry } from "./cms";
|
||||
import {
|
||||
getPosts,
|
||||
getPostBySlug,
|
||||
getTags,
|
||||
getTextFragmentBySlug,
|
||||
getCalendarBySlug,
|
||||
getCalendarItemBySlug,
|
||||
} from "./cms";
|
||||
import type { RowContentLayout } from "./block-types";
|
||||
import type { CalendarItemData } from "./block-types";
|
||||
import { ensureTransformedImage } 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
|
||||
export function getPostImageUrl(
|
||||
postImage: PostEntry["postImage"],
|
||||
): string | null {
|
||||
if (!postImage || typeof postImage === "string") return null;
|
||||
const obj = postImage as {
|
||||
src?: string;
|
||||
file?: { url?: string };
|
||||
fields?: { file?: { url?: string } };
|
||||
};
|
||||
const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url;
|
||||
return typeof url === "string"
|
||||
? url.startsWith("//")
|
||||
? `https:${url}`
|
||||
: url
|
||||
: 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 ?? "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 PostWithEventIndex = PostEntry & {
|
||||
isEvent?: boolean;
|
||||
eventDate?: string;
|
||||
_resolvedImageUrl?: string;
|
||||
};
|
||||
|
||||
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
||||
return posts.map((p) => ({
|
||||
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 PostWithEventIndex)._resolvedImageUrl ?? null,
|
||||
created: p.created ?? null,
|
||||
isEvent: Boolean((p as PostWithEventIndex).isEvent),
|
||||
eventDate: (p as PostWithEventIndex).eventDate ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
[posts, tags] = await Promise.all([
|
||||
getPosts({ resolve: ["postImage"] }),
|
||||
getTags(),
|
||||
]);
|
||||
posts = filterHiddenPosts(sortPostsByDate(posts));
|
||||
const tagsMap = await getTagsMap();
|
||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||
for (const post of posts) {
|
||||
const raw = getPostImageUrl(post.postImage);
|
||||
if (raw) {
|
||||
try {
|
||||
const url = await ensureTransformedImage(raw, {
|
||||
width: 400,
|
||||
height: 267,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
});
|
||||
(post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
|
||||
} 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();
|
||||
return (posts as PostWithEventIndex[])
|
||||
.filter(
|
||||
(p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= now,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.eventDate ?? 0).getTime() -
|
||||
new Date(b.eventDate ?? 0).getTime(),
|
||||
)
|
||||
.slice(0, max) as PostEntry[];
|
||||
}
|
||||
|
||||
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[];
|
||||
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;
|
||||
if (item.allPosts) {
|
||||
if (allPosts === null)
|
||||
allPosts = await getPosts({
|
||||
_sort: "created",
|
||||
_order: "desc",
|
||||
resolve: "all",
|
||||
});
|
||||
let list = filterHiddenPosts(sortPostsByDate(allPosts));
|
||||
const rawFilter = item.filterByTag ?? [];
|
||||
const tagSlugs = Array.isArray(rawFilter)
|
||||
? rawFilter
|
||||
.map((t) =>
|
||||
typeof t === "string"
|
||||
? t
|
||||
: ((t as { _slug?: string })?._slug ?? ""),
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs);
|
||||
const limit =
|
||||
typeof item.numberItems === "number" ? item.numberItems : 9999;
|
||||
item.postsResolved = list.slice(0, limit);
|
||||
} else if (Array.isArray(item.posts) && item.posts.length > 0) {
|
||||
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 raw = getPostImageUrl(post.postImage);
|
||||
if (raw) {
|
||||
try {
|
||||
const url = await ensureTransformedImage(raw, {
|
||||
width: 400,
|
||||
height: 267,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
});
|
||||
(
|
||||
post as PostEntry & { _resolvedImageUrl?: string }
|
||||
)._resolvedImageUrl = url;
|
||||
} 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user