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,128 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getPostBySlug, getPageConfigBySlug, getPageConfigs } from '$lib/cms';
|
||||
import {
|
||||
resolveContentImages,
|
||||
ensureTransformedImage,
|
||||
processMarkdownHtmlImages,
|
||||
} from '$lib/rusty-image';
|
||||
import {
|
||||
resolvePostTagsInPost,
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
getPostImageUrl,
|
||||
formatPostDate,
|
||||
} from '$lib/blog-utils';
|
||||
import { POST_RESOLVE } from '$lib/constants';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
const { slug } = params;
|
||||
const translations = locals.translations ?? {};
|
||||
|
||||
const post = await getPostBySlug(slug, {
|
||||
locale: 'de',
|
||||
resolve: POST_RESOLVE,
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
error(404, 'Beitrag nicht gefunden');
|
||||
}
|
||||
|
||||
await resolveContentImages(post);
|
||||
const tagsMap = await resolvePostOverviewBlocks(post);
|
||||
await resolveSearchableTextBlocks(post, tagsMap);
|
||||
resolvePostTagsInPost(post, tagsMap);
|
||||
|
||||
const rawPostImageUrl = getPostImageUrl(post.postImage);
|
||||
const postImageUrl = rawPostImageUrl
|
||||
? await ensureTransformedImage(rawPostImageUrl, {
|
||||
width: 150,
|
||||
height: 200,
|
||||
fit: 'cover',
|
||||
})
|
||||
: null;
|
||||
|
||||
const postDate = formatPostDate(post.created);
|
||||
|
||||
const postContent = (post as { content?: string }).content;
|
||||
let contentHtml =
|
||||
typeof postContent === 'string' && postContent.trim()
|
||||
? (marked.parse(postContent) as string)
|
||||
: '';
|
||||
if (contentHtml) {
|
||||
contentHtml = await processMarkdownHtmlImages(contentHtml);
|
||||
}
|
||||
|
||||
const hasRowContent =
|
||||
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
|
||||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
|
||||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
|
||||
|
||||
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
|
||||
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
|
||||
const eventDateLabel =
|
||||
isEvent && eventDateRaw
|
||||
? new Date(eventDateRaw).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: null;
|
||||
const eventLocation = isEvent
|
||||
? ((post as { eventLocation?: { text?: string; lat?: number; lng?: number } }).eventLocation ?? null)
|
||||
: null;
|
||||
|
||||
const hasCoords = !!(eventLocation?.lat && eventLocation?.lng);
|
||||
const _lat = eventLocation?.lat ?? 0;
|
||||
const _lng = eventLocation?.lng ?? 0;
|
||||
const mapEmbedUrl = hasCoords
|
||||
? `https://www.openstreetmap.org/export/embed.html?bbox=${_lng - 0.01},${_lat - 0.007},${_lng + 0.01},${_lat + 0.007}&layer=mapnik&marker=${_lat},${_lng}`
|
||||
: null;
|
||||
const mapLinkUrl = hasCoords
|
||||
? `https://www.openstreetmap.org/?mlat=${eventLocation!.lat}&mlon=${eventLocation!.lng}#map=15/${eventLocation!.lat}/${eventLocation!.lng}`
|
||||
: eventLocation?.text
|
||||
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
|
||||
: null;
|
||||
|
||||
const pageConfig =
|
||||
(await getPageConfigBySlug('page-config-default', { locale: 'de' })) ??
|
||||
(await getPageConfigs({ locale: 'de', per_page: 1 }))[0] ??
|
||||
null;
|
||||
const commentSlotRaw =
|
||||
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? '';
|
||||
const commentSlot = commentSlotRaw
|
||||
.replace(/\{\{PAGE_ID\}\}/g, slug)
|
||||
.replace(/\{\{PAGE_URL\}\}/g, url.href)
|
||||
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
|
||||
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
|
||||
|
||||
return {
|
||||
post,
|
||||
postImageUrl,
|
||||
postDate,
|
||||
contentHtml,
|
||||
hasRowContent,
|
||||
isEvent,
|
||||
eventDateRaw,
|
||||
eventDateLabel,
|
||||
eventLocationText: eventLocation?.text ?? null,
|
||||
mapEmbedUrl,
|
||||
mapLinkUrl,
|
||||
commentSlot: showCommentSection ? commentSlot : '',
|
||||
translations,
|
||||
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
|
||||
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
|
||||
socialImage: postImageUrl ?? undefined,
|
||||
breadcrumbItems: [
|
||||
{ href: '/', label: 'Start' },
|
||||
{ href: '/posts', label: 'Beiträge' },
|
||||
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import ContentRows from '$lib/components/ContentRows.svelte';
|
||||
import Tag from '$lib/components/Tag.svelte';
|
||||
import Tags from '$lib/components/Tags.svelte';
|
||||
import EventBadges from '$lib/components/EventBadges.svelte';
|
||||
import EventMap from '$lib/components/EventMap.svelte';
|
||||
import Accordion from '$lib/components/Accordion.svelte';
|
||||
import { t, T } from '$lib/translations';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.seoTitle}</title>
|
||||
{#if data.seoDescription}
|
||||
<meta name="description" content={data.seoDescription} />
|
||||
{/if}
|
||||
{#if data.postImageUrl}
|
||||
<meta property="og:image" content={data.postImageUrl} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if data.isEvent && (data.eventDateLabel || data.eventLocationText)}
|
||||
<div class="mb-6">
|
||||
<EventBadges
|
||||
eventDate={data.eventDateRaw}
|
||||
locationText={data.eventLocationText}
|
||||
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:flex gap-2 items-end">
|
||||
{#if data.postImageUrl}
|
||||
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
||||
<img
|
||||
src={data.postImageUrl}
|
||||
alt=""
|
||||
class="block object-cover"
|
||||
width="150"
|
||||
height="200"
|
||||
style="aspect-ratio: 1.3333"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
|
||||
{#if data.postDate}
|
||||
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
|
||||
<Tag label={data.postDate} variant="date" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden">
|
||||
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="mt-8">
|
||||
{#if data.contentHtml}
|
||||
<div class="content markdown max-w-none prose prose-zinc">
|
||||
{@html data.contentHtml}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.hasRowContent}
|
||||
<ContentRows layout={data.post} translations={data.translations} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
{#if data.isEvent && (data.mapEmbedUrl || data.mapLinkUrl)}
|
||||
<EventMap
|
||||
embedUrl={data.mapEmbedUrl}
|
||||
linkUrl={data.mapLinkUrl}
|
||||
locationText={data.eventLocationText}
|
||||
label={t(data.translations, T.post_map)}
|
||||
translations={data.translations}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if data.commentSlot}
|
||||
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
|
||||
<div class="p-8 border border-zinc-200 rounded-lg">
|
||||
{@html data.commentSlot}
|
||||
</div>
|
||||
</Accordion>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user