Enhance project structure and functionality by adding new components and integrations. Updated .gitignore to exclude history and transformed images. Integrated Svelte, sitemap, and icon support in astro.config.mjs. Added multiple new components including BlogOverview, ContentRows, Footer, Header, and various block components for improved content management. Updated package.json with new dependencies and modified scripts for build and development processes.
This commit is contained in:
@@ -22,3 +22,7 @@ pnpm-debug.log*
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
.history/
|
||||
public/images/transformed/
|
||||
public/pagefind
|
||||
+16
-1
@@ -1,10 +1,25 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import svelte from '@astrojs/svelte';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import icon from 'astro-icon';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
// site wird für Sitemap und canonical/og-URLs genutzt (PUBLIC_SITE_URL oder Fallback)
|
||||
const site = process.env.PUBLIC_SITE_URL || 'https://example.com';
|
||||
|
||||
export default defineConfig({
|
||||
site,
|
||||
integrations: [
|
||||
svelte(),
|
||||
sitemap(),
|
||||
icon({
|
||||
include: {
|
||||
mdi: ['close', 'link', 'magnify', 'menu'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
}
|
||||
|
||||
+17
-4
@@ -3,19 +3,32 @@
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"dev": "yarn generate:api-types; astro dev",
|
||||
"build": "yarn generate:api-types && astro build && pagefind --site dist && cp -r dist/pagefind public/",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs"
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"postinstall": "yarn generate:api-types || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
"@astrojs/svelte": "^7.2.5",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.17.1",
|
||||
"marked": "^17.0.2",
|
||||
"svelte": "^5.51.2",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openapi-typescript": "^7.4.0"
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@types/node": "^22.10.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"openapi-typescript": "^7.4.0",
|
||||
"pagefind": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import Tag from "./Tag.svelte";
|
||||
import type { PostEntry } from "../lib/cms";
|
||||
import { postHref } from "../lib/blog-utils";
|
||||
|
||||
interface TagEntry {
|
||||
_slug?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let {
|
||||
posts = [],
|
||||
tags = [],
|
||||
activeTag = null,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
totalPosts = 0,
|
||||
basePath = "/posts",
|
||||
}: {
|
||||
posts: PostEntry[];
|
||||
tags: TagEntry[];
|
||||
activeTag: string | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalPosts?: number;
|
||||
basePath?: string;
|
||||
} = $props();
|
||||
|
||||
function tagHref(tagSlug: string | null): string {
|
||||
if (!tagSlug) return basePath;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
|
||||
}
|
||||
|
||||
function pageHref(page: number): string {
|
||||
if (page <= 1) return tagHref(activeTag);
|
||||
const tagPart = activeTag
|
||||
? `/tag/${encodeURIComponent(activeTag)}`
|
||||
: "";
|
||||
return `${basePath}${tagPart}/page/${page}`;
|
||||
}
|
||||
|
||||
const showPagination = $derived(totalPages > 1);
|
||||
const hasPrev = $derived(currentPage > 1);
|
||||
const hasNext = $derived(currentPage < totalPages);
|
||||
const prevPage = $derived(currentPage - 1);
|
||||
const nextPage = $derived(currentPage + 1);
|
||||
const totalCount = $derived(totalPosts);
|
||||
|
||||
const pageNumbers = $derived.by(() => {
|
||||
const max = 5;
|
||||
let start = Math.max(1, currentPage - Math.floor(max / 2));
|
||||
const end = Math.min(totalPages, start + max - 1);
|
||||
if (end - start + 1 < max) start = Math.max(1, end - max + 1);
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
});
|
||||
|
||||
const paginationLinkClass =
|
||||
"h-6 w-6 mx-[2px] flex justify-center items-center text-slate-950 no-underline text-xs font-medium border border-gray-600 bg-white shadow-md";
|
||||
</script>
|
||||
|
||||
<div class="blog-overview">
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label="Nach Tag filtern">
|
||||
<Tag
|
||||
label="Alle"
|
||||
href={tagHref(null)}
|
||||
variant={activeTag ? "blue" : "green"}
|
||||
active={!activeTag}
|
||||
/>
|
||||
{#each tags as tag}
|
||||
{@const slug = tag._slug ?? ""}
|
||||
<Tag
|
||||
label={tag.name}
|
||||
href={tagHref(slug)}
|
||||
variant={activeTag === slug ? "inactive" : "green"}
|
||||
active={activeTag === slug}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPagination}
|
||||
<div>
|
||||
<div class="inline-flex py-4">
|
||||
<div class="flex">
|
||||
{#if hasPrev}
|
||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
|
||||
←
|
||||
</a>
|
||||
{/if}
|
||||
{#each pageNumbers as num}
|
||||
<a
|
||||
href={pageHref(num)}
|
||||
class="{paginationLinkClass} {num === currentPage ? 'opacity-50' : ''}"
|
||||
aria-current={num === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{num}
|
||||
</a>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
|
||||
→
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="py-2 grid grid-cols-1 gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard
|
||||
post={post}
|
||||
href={postHref(post)}
|
||||
tagBase={`${basePath}/tag`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if showPagination}
|
||||
<div class="inline-flex py-4">
|
||||
<div class="flex">
|
||||
{#if hasPrev}
|
||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
|
||||
←
|
||||
</a>
|
||||
{/if}
|
||||
{#each pageNumbers as num}
|
||||
<a
|
||||
href={pageHref(num)}
|
||||
class="{paginationLinkClass} {num === currentPage ? 'opacity-50' : ''}"
|
||||
aria-current={num === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{num}
|
||||
</a>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
|
||||
→
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<div class="bg-white rounded-md font-thin">
|
||||
<div class="inline-flex text-xs p-2">
|
||||
{#if totalCount > 0}
|
||||
{posts.length} von {totalCount} Beiträgen, Seite {currentPage} von {totalPages}
|
||||
{:else}
|
||||
Seite {currentPage} von {totalPages}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
|
||||
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
|
||||
import HtmlBlock from "./blocks/HtmlBlock.svelte";
|
||||
import IframeBlock from "./blocks/IframeBlock.svelte";
|
||||
import ImageBlock from "./blocks/ImageBlock.svelte";
|
||||
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
|
||||
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
||||
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
||||
import LinkListBlock from "./blocks/LinkListBlock.svelte";
|
||||
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
|
||||
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
|
||||
import { isBlockLayoutBreakout } from "../lib/block-layout";
|
||||
import type { BlockLayout } from "../lib/block-layout";
|
||||
import type {
|
||||
RowContentLayout,
|
||||
ResolvedBlock,
|
||||
MarkdownBlockData,
|
||||
HeadlineBlockData,
|
||||
HtmlBlockData,
|
||||
IframeBlockData,
|
||||
ImageBlockData,
|
||||
ImageGalleryBlockData,
|
||||
YoutubeVideoBlockData,
|
||||
QuoteBlockData,
|
||||
LinkListBlockData,
|
||||
PostOverviewBlockData,
|
||||
SearchableTextBlockData,
|
||||
} from "../lib/block-types";
|
||||
|
||||
let { layout, class: className = "" }: { layout: RowContentLayout; class?: string } = $props();
|
||||
|
||||
function isResolvedBlock(item: unknown): item is ResolvedBlock {
|
||||
return typeof item === "object" && item !== null;
|
||||
}
|
||||
|
||||
function blockType(item: ResolvedBlock): string {
|
||||
return (item._type as string) ?? "unknown";
|
||||
}
|
||||
|
||||
function justifyToClass(j?: string): string {
|
||||
switch (j) {
|
||||
case "end": return "justify-end";
|
||||
case "center": return "justify-center";
|
||||
case "between": return "justify-between";
|
||||
case "around": return "justify-around";
|
||||
case "evenly": return "justify-evenly";
|
||||
default: return "justify-start";
|
||||
}
|
||||
}
|
||||
|
||||
function alignToClass(a?: string): string {
|
||||
switch (a) {
|
||||
case "end": return "items-end";
|
||||
case "center": return "items-center";
|
||||
case "baseline": return "items-baseline";
|
||||
case "stretch": return "items-stretch";
|
||||
default: return "items-start";
|
||||
}
|
||||
}
|
||||
|
||||
const rows = $derived([
|
||||
{
|
||||
content: layout.row1Content ?? [],
|
||||
justify: justifyToClass(layout.row1JustifyContent),
|
||||
align: alignToClass(layout.row1AlignItems),
|
||||
},
|
||||
{
|
||||
content: layout.row2Content ?? [],
|
||||
justify: justifyToClass(layout.row2JustifyContent),
|
||||
align: alignToClass(layout.row2AlignItems),
|
||||
},
|
||||
{
|
||||
content: layout.row3Content ?? [],
|
||||
justify: justifyToClass(layout.row3JustifyContent),
|
||||
align: alignToClass(layout.row3AlignItems),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="content my-6 {className}">
|
||||
{#each rows as row}
|
||||
{#if row.content.length > 0}
|
||||
<div
|
||||
class="content-row grid grid-cols-12 gap-4 {row.justify} {row.align}"
|
||||
role="region"
|
||||
data-cf-type="grid-row"
|
||||
>
|
||||
{#each row.content as item}
|
||||
{#if isResolvedBlock(item)}
|
||||
{#snippet blockContent()}
|
||||
{#if blockType(item) === "markdown"}
|
||||
<MarkdownBlock block={item as MarkdownBlockData} />
|
||||
{:else if blockType(item) === "headline"}
|
||||
<HeadlineBlock block={item as HeadlineBlockData} />
|
||||
{:else if blockType(item) === "html"}
|
||||
<HtmlBlock block={item as HtmlBlockData} />
|
||||
{:else if blockType(item) === "iframe"}
|
||||
<IframeBlock block={item as IframeBlockData} />
|
||||
{:else if blockType(item) === "image"}
|
||||
<ImageBlock block={item as ImageBlockData} />
|
||||
{:else if blockType(item) === "image_gallery"}
|
||||
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
|
||||
{:else if blockType(item) === "youtube_video"}
|
||||
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
|
||||
{:else if blockType(item) === "quote"}
|
||||
<QuoteBlock block={item as QuoteBlockData} />
|
||||
{:else if blockType(item) === "link_list"}
|
||||
<LinkListBlock block={item as LinkListBlockData} />
|
||||
{:else if blockType(item) === "post_overview"}
|
||||
<PostOverviewBlock block={item as PostOverviewBlockData} />
|
||||
{:else if blockType(item) === "searchable_text"}
|
||||
<SearchableTextBlock block={item as SearchableTextBlockData} />
|
||||
{:else}
|
||||
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Unbekannter Block: <code>{blockType(item)}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
|
||||
<div class="col-span-12">
|
||||
<div class="w-screen relative left-1/2 -translate-x-1/2 max-w-none bg-linear-to-b from-green-50 to-green-100 border-y border-green-100">
|
||||
<div class="container-custom py-4">
|
||||
{@render blockContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render blockContent()}
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import ContentRows from "./ContentRows.svelte";
|
||||
import type { RowContentLayout } from "../lib/block-types";
|
||||
|
||||
interface FooterEntry extends RowContentLayout {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { footer = null }: { footer?: FooterEntry | null } = $props();
|
||||
|
||||
const justifyClass = $derived(
|
||||
footer?.row1JustifyContent === "end"
|
||||
? "justify-end"
|
||||
: footer?.row1JustifyContent === "between"
|
||||
? "justify-between"
|
||||
: footer?.row1JustifyContent === "around"
|
||||
? "justify-around"
|
||||
: footer?.row1JustifyContent === "evenly"
|
||||
? "justify-evenly"
|
||||
: footer?.row1JustifyContent === "start"
|
||||
? "justify-start"
|
||||
: "justify-center",
|
||||
);
|
||||
|
||||
const hasRowContent = $derived(
|
||||
!!footer &&
|
||||
((footer.row1Content?.length ?? 0) > 0 ||
|
||||
(footer.row2Content?.length ?? 0) > 0 ||
|
||||
(footer.row3Content?.length ?? 0) > 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if footer}
|
||||
<footer
|
||||
class="mt-auto bg-zinc-950 text-white py-6"
|
||||
data-footer-id={footer.id}
|
||||
>
|
||||
<div class="container-custom py-6">
|
||||
{#if hasRowContent}
|
||||
<div class="content-footer">
|
||||
<ContentRows layout={footer} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-white/80 {justifyClass} flex">
|
||||
© {new Date().getFullYear()} RustyAstro
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import "../lib/iconify-offline";
|
||||
import { slide } from "svelte/transition";
|
||||
import Icon from "@iconify/svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import { overlayOpen, overlayClose } from "../lib/overlay-store";
|
||||
|
||||
interface NavLink {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
href: string;
|
||||
icon: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { links = [], socialLinks = [], logoUrl = null }: {
|
||||
links?: NavLink[];
|
||||
socialLinks?: SocialLink[];
|
||||
logoUrl?: string | null;
|
||||
} = $props();
|
||||
let menuOpen = $state(false);
|
||||
let searchOpen = $state(false);
|
||||
|
||||
function toggleMenu() {
|
||||
if (menuOpen) {
|
||||
menuOpen = false;
|
||||
} else {
|
||||
searchOpen = false;
|
||||
menuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen = false;
|
||||
}
|
||||
|
||||
function closeOverlays() {
|
||||
menuOpen = false;
|
||||
searchOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (searchOpen) menuOpen = false;
|
||||
});
|
||||
$effect(() => {
|
||||
if (menuOpen) searchOpen = false;
|
||||
});
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const open = menuOpen || searchOpen;
|
||||
overlayOpen.set(open);
|
||||
overlayClose.set(open ? closeOverlays : null);
|
||||
});
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const open = menuOpen || searchOpen;
|
||||
if (open) window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
document.documentElement.style.overflow = open ? "hidden" : "";
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.documentElement.style.overflow = "";
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header
|
||||
id="horizontal-navigation"
|
||||
class="sticky top-0 z-20 backdrop-blur bg-zinc-950/90 shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between container-custom py-2">
|
||||
<a
|
||||
href="/"
|
||||
class="logo flex items-center text-white font-bold text-lg tracking-wide hover:text-green-100 transition-colors"
|
||||
>
|
||||
{#if logoUrl && logoUrl.trim() !== ""}
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
class="h-12 w-auto object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<span>RustyAstro</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Desktop: horizontale Nav (ab lg) -->
|
||||
<nav
|
||||
class="hidden lg:flex items-center gap-4"
|
||||
aria-label="Hauptnavigation"
|
||||
>
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-white/90 hover:text-green-100 hover:animate-pulse transition-all text-xs tracking-wide"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div><span class="text-white/90">|</span></div>
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-1.5 -m-1.5 text-white/90 hover:text-green-100 hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={social.label || "Social Media"}
|
||||
>
|
||||
<Icon icon={social.icon} aria-hidden="true" />
|
||||
</a>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon icon="mdi:close" class="text-red-400!" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" class="text-emerald-400!" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
|
||||
<div class="lg:hidden flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl text-red-400!" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" class="text-2xl text-emerald-400!" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
|
||||
aria-label={menuOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
aria-controls="mobile-nav"
|
||||
onclick={toggleMenu}
|
||||
>
|
||||
<span class="sr-only">{menuOpen ? "Menü schließen" : "Menü öffnen"}</span>
|
||||
{#if menuOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Search bind:open={searchOpen} />
|
||||
|
||||
<!-- Mobile: Dropdown-Menü (unter lg) -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="lg:hidden border-t border-white/10 bg-zinc-950/95 backdrop-blur overflow-hidden"
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<nav class="container-custom py-4 flex flex-col gap-1" aria-label="Hauptnavigation">
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="block py-3 px-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors text-sm"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if socialLinks.length > 0}
|
||||
<div class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-white/10">
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex p-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors"
|
||||
aria-label={social.label || "Social Media"}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<Icon icon={social.icon} class="text-[1.5rem]" aria-hidden="true" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { overlayOpen, overlayClose } from "../lib/overlay-store";
|
||||
|
||||
function handleClick() {
|
||||
get(overlayClose)?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $overlayOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
|
||||
aria-label="Menü bzw. Suche schließen"
|
||||
onclick={handleClick}
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { PostEntry } from "../lib/cms";
|
||||
import PostMeta from "./PostMeta.svelte";
|
||||
|
||||
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
|
||||
|
||||
let {
|
||||
post,
|
||||
href,
|
||||
tagBase = "/posts/tag",
|
||||
}: {
|
||||
post: PostWithImage;
|
||||
href: string;
|
||||
tagBase?: string;
|
||||
} = $props();
|
||||
|
||||
const resolvedImg = $derived(post._resolvedImageUrl);
|
||||
const bgStyle = $derived(resolvedImg ? `background-image: url(${resolvedImg})` : "");
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white relative min-h-[200px]"
|
||||
>
|
||||
{#if resolvedImg}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center opacity-10"
|
||||
style={bgStyle}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="relative flex-1 flex flex-col justify-start p-4"
|
||||
>
|
||||
<PostMeta
|
||||
date={post.created ?? post.date ?? null}
|
||||
tags={post.postTag ?? []}
|
||||
tagBase={null}
|
||||
/>
|
||||
<div>
|
||||
<h5 class="line-clamp-2 font-medium leading-tight mb-1">
|
||||
{post.headline ?? post.linkName ?? post.slug ?? post._slug}
|
||||
</h5>
|
||||
{#if post.subheadline}
|
||||
<div class="text-xs line-clamp-2 mb-1 ">
|
||||
{post.subheadline}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.excerpt}
|
||||
<p class="text-xs font-light line-clamp-3 ">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { formatPostDate } from "../lib/blog-utils";
|
||||
import Tag from "./Tag.svelte";
|
||||
import Tags from "./Tags.svelte";
|
||||
|
||||
type TagItem = string | { name?: string; _slug?: string };
|
||||
type TagVariant = "white" | "green" | "blue";
|
||||
|
||||
let {
|
||||
date = null,
|
||||
tags = [],
|
||||
tagVariant = "white",
|
||||
tagBase = null,
|
||||
}: {
|
||||
date?: string | null;
|
||||
tags?: TagItem[];
|
||||
tagVariant?: TagVariant;
|
||||
tagBase?: string | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-1 min-w-0 flex flex-nowrap gap-1 items-center overflow-x-auto overflow-y-hidden p-1 pb-4">
|
||||
{#if date}
|
||||
<Tag label={formatPostDate(date)} variant="date" />
|
||||
{/if}
|
||||
<div class="flex flex-nowrap gap-1 items-center shrink-0">
|
||||
<Tags tags={tags} variant={tagVariant} tagBase={tagBase} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
/**
|
||||
* RustyImage: Bild-URL über RustyCMS /api/transform laden, in public speichern, lokal ausliefern.
|
||||
* Nutzung: <RustyImage src={url} width={34} height={50} alt="..." />
|
||||
* Unterstützt alle API-Transformationen: width, height, ar, fit, format, quality.
|
||||
* Im Svelte-Kontext: RustyImage.svelte verwenden und dort den bereits aufgelösten Pfad als src übergeben.
|
||||
*/
|
||||
import { ensureTransformedImage } from '../lib/rusty-image';
|
||||
|
||||
interface Props {
|
||||
/** Externe Bild-URL (z. B. Contentful). */
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Aspect ratio, z. B. "1:1" oder "16:9". */
|
||||
ar?: string;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
format?: 'jpeg' | 'png' | 'webp' | 'avif';
|
||||
quality?: number;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
}
|
||||
|
||||
const { src, width, height, ar, fit, format, quality, alt = '', class: className = '', loading = 'lazy' } = Astro.props;
|
||||
|
||||
const resolvedSrc = await ensureTransformedImage(src, {
|
||||
width,
|
||||
height,
|
||||
ar,
|
||||
fit,
|
||||
format,
|
||||
quality,
|
||||
});
|
||||
---
|
||||
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
class={className}
|
||||
loading={loading}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* RustyImage (Svelte): Gleiche API wie RustyImage.astro, aber für Svelte.
|
||||
* Wichtig: In Svelte kann die Auflösung (Transform-API + Speichern in public) nicht laufen.
|
||||
* Du musst hier bereits den aufgelösten Pfad übergeben (z. B. /images/transformed/xyz.jpg).
|
||||
* Die Auflösung passiert in der Astro-Seite mit ensureTransformedImage() – das Ergebnis
|
||||
* übergibst du als src an diese Komponente.
|
||||
*/
|
||||
interface Props {
|
||||
/** Bereits aufgelöster Bildpfad (z. B. von ensureTransformedImage in Astro). */
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
}
|
||||
|
||||
let { src, width, height, alt = "", class: className = "", loading = "lazy" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
class={className}
|
||||
{loading}
|
||||
/>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { tick } from "svelte";
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
let pagefindInstance: { destroy?: () => void } | null = $state(null);
|
||||
let pagefindLoaded = $state(false);
|
||||
|
||||
function closeSearch() {
|
||||
if (typeof pagefindInstance?.destroy === "function") {
|
||||
pagefindInstance.destroy();
|
||||
pagefindInstance = null;
|
||||
}
|
||||
pagefindLoaded = false;
|
||||
open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeSearch();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
if (typeof pagefindInstance?.destroy === "function") {
|
||||
pagefindInstance.destroy();
|
||||
pagefindInstance = null;
|
||||
}
|
||||
pagefindLoaded = false;
|
||||
return;
|
||||
}
|
||||
if (typeof window === "undefined") return;
|
||||
const PagefindUI = (window as unknown as { PagefindUI?: new (opts: Record<string, unknown>) => { destroy: () => void } }).PagefindUI;
|
||||
if (!PagefindUI) return;
|
||||
let cancelled = false;
|
||||
tick().then(() => {
|
||||
if (cancelled) return;
|
||||
const el = document.getElementById("pagefind-ui");
|
||||
if (!el?.isConnected) return;
|
||||
try {
|
||||
pagefindInstance = new PagefindUI({ element: "#pagefind-ui", showSubResults: true });
|
||||
pagefindLoaded = true;
|
||||
} catch (_) {
|
||||
pagefindInstance = null;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id="search-panel"
|
||||
class="border-t border-white/10 bg-zinc-950/95 backdrop-blur overflow-hidden"
|
||||
role="region"
|
||||
aria-label="Suche"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]">
|
||||
<div id="pagefind-ui" class="pagefind-ui-wrapper"></div>
|
||||
{#if !pagefindLoaded}
|
||||
<div class="pagefind-fallback max-w-xl mx-auto">
|
||||
<div class="h-10 bg-black"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.pagefind-ui-wrapper),
|
||||
:global(#pagefind-ui) {
|
||||
--pagefind-ui-scale: .7;
|
||||
--pagefind-ui-primary: #22c55e;
|
||||
--pagefind-ui-text: #e4e4e7;
|
||||
--pagefind-ui-background: #000;
|
||||
--pagefind-ui-border : #fff;
|
||||
--pagefind-ui-border: #3f3f46;
|
||||
--pagefind-ui-tag: #3f3f46;
|
||||
--pagefind-ui-font: var(--font-body);
|
||||
--pagefind-ui-border-width: 2px;
|
||||
--pagefind-ui-border-radius: 0;
|
||||
--pagefind-ui-border-color: var(--color-link);
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
type Variant = "white" | "green" | "blue" | "inactive" | "date";
|
||||
|
||||
let {
|
||||
label,
|
||||
variant = "white",
|
||||
href = null,
|
||||
active = false,
|
||||
onclick = null,
|
||||
}: {
|
||||
label: string;
|
||||
variant?: Variant;
|
||||
href?: string | null;
|
||||
active?: boolean;
|
||||
onclick?: (() => void) | null;
|
||||
} = $props();
|
||||
|
||||
const baseClass =
|
||||
"inline-flex shrink-0 whitespace-nowrap py-1 px-2 text-[.6rem] rounded-full transition-all ring-1 ring-white/50 no-underline!";
|
||||
const variantClass = $derived.by(() => {
|
||||
if (variant === "white") return "bg-gradient-to-b from-white to-gray-100 text-black! ring-black/5!";
|
||||
if (variant === "green") return "bg-gradient-to-b from-green-300 to-green-400 text-black! ";
|
||||
if (variant === "blue") return "bg-gradient-to-b from-teal-300 to-teal-400 text-black! ";
|
||||
if (variant === "inactive") return "bg-gradient-to-t from-gray-200 to-gray-300 text-gray-500! shadow-none ring-black/15!";
|
||||
if (variant === "date") return "bg-green-700 text-white! ring-white!";
|
||||
return "bg-gray-200 text-gray-500";
|
||||
});
|
||||
const shadowClass = $derived.by(() => {
|
||||
if (!href && !onclick) return "shadow-none";
|
||||
if (variant === "date") return "shadow-[0_0_6px_rgba(0,0,0,0.3)]";
|
||||
return "shadow-[0_2px_6px_rgba(0,0,0,0.2)]";
|
||||
});
|
||||
const classes = $derived.by(() => `${baseClass} ${variantClass} ${shadowClass} ${active ? "pointer-events-none" : ""}`);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a href={href} class={classes} aria-current={active ? "true" : undefined}>{label}</a>
|
||||
{:else if onclick}
|
||||
<button type="button" class={classes} aria-current={active ? "true" : undefined} onclick={onclick}>{label}</button>
|
||||
{:else}
|
||||
<span class={classes}>{label}</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Tag from "./Tag.svelte";
|
||||
|
||||
type TagItem = string | { name?: string; _slug?: string };
|
||||
type Variant = "white" | "green" | "blue";
|
||||
|
||||
let {
|
||||
tags = [],
|
||||
variant = "white",
|
||||
tagBase = null,
|
||||
}: {
|
||||
tags?: TagItem[];
|
||||
variant?: Variant;
|
||||
tagBase?: string | null;
|
||||
} = $props();
|
||||
|
||||
function displayName(t: TagItem): string {
|
||||
if (typeof t === "string") return t;
|
||||
return (t as { name?: string; _slug?: string })?.name ?? (t as { _slug?: string })?._slug ?? "";
|
||||
}
|
||||
|
||||
function slug(t: TagItem): string {
|
||||
if (typeof t === "string") return t;
|
||||
return (t as { _slug?: string })?._slug ?? (t as { name?: string })?.name ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each (tags ?? []) as t}
|
||||
{@const name = displayName(t)}
|
||||
{#if name}
|
||||
<Tag
|
||||
label={name}
|
||||
variant={variant}
|
||||
href={tagBase ? `${tagBase}/${slug(t)}` : null}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
|
||||
* und Titel/Subtitle der aktuellen Page (Markdown).
|
||||
*/
|
||||
import { marked } from "marked";
|
||||
import type { FullwidthBannerEntry } from "../lib/cms";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
interface Props {
|
||||
banner: FullwidthBannerEntry | null;
|
||||
resolvedImages?: string[];
|
||||
/** Headline der Page (Markdown). */
|
||||
headline?: string;
|
||||
/** Subheadline der Page (Markdown). */
|
||||
subheadline?: string;
|
||||
}
|
||||
|
||||
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
|
||||
|
||||
const headlineHtml = $derived(
|
||||
headline?.trim() ? (marked.parseInline(headline) as string) : "",
|
||||
);
|
||||
const subheadlineHtml = $derived(
|
||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
||||
);
|
||||
|
||||
const hasImage = $derived(resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim()));
|
||||
const hasText = $derived(banner?.text != null && banner.text !== "");
|
||||
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
|
||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<div class="w-full">
|
||||
{#if hasImage}
|
||||
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
|
||||
<img
|
||||
src={resolvedImages[0]}
|
||||
alt={banner?.headline ?? headline ?? ""}
|
||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
|
||||
width={1920}
|
||||
height={600}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
{#if hasPageTitle}
|
||||
<div class="absolute inset-0 flex items-center justify-start ">
|
||||
<div class="container-custom mx-auto px-6 relative">
|
||||
{#if headlineHtml}
|
||||
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
|
||||
{/if}
|
||||
<div></div>
|
||||
{#if subheadlineHtml}
|
||||
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hasPageTitle}
|
||||
<div class="border-b border-zinc-200 container mx-auto px-6 py-4">
|
||||
{#if headlineHtml}
|
||||
<h1 class="text-2xl font-bold text-zinc-900">{@html headlineHtml}</h1>
|
||||
{/if}
|
||||
{#if subheadlineHtml}
|
||||
<h2 class="text-lg text-zinc-600 mt-1">{@html subheadlineHtml}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasText}
|
||||
<div class="w-full bg-green-700 text-white text-center py-2 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="text-sm font-medium">{banner!.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { HeadlineBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: HeadlineBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const alignClass = $derived(
|
||||
block.align === "center"
|
||||
? "text-center"
|
||||
: block.align === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="headline" data-block-slug={block._slug}>
|
||||
<svelte:element
|
||||
this={Tag}
|
||||
class={alignClass}
|
||||
>
|
||||
{block.text ?? ""}
|
||||
</svelte:element>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { HtmlBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: HtmlBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} content-html"
|
||||
data-block-type="html"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{@html block.html ?? ""}
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { IframeBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: IframeBlockData } = $props();
|
||||
|
||||
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
|
||||
let overlayActive = $state(true);
|
||||
|
||||
/** Nimmt entweder eine reine URL oder Iframe-HTML und liefert die src-URL. */
|
||||
function getIframeSrc(raw: string | undefined): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) return "";
|
||||
const s = raw.trim();
|
||||
if (s.startsWith("http") || s.startsWith("//")) return s;
|
||||
const match = s.match(/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const src = $derived(getIframeSrc(block.iframe));
|
||||
|
||||
function activateMap() {
|
||||
overlayActive = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="iframe" data-block-slug={block._slug}>
|
||||
{#if src}
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
|
||||
<iframe
|
||||
title={block.content ?? "Embed"}
|
||||
src={src}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{#if overlayActive}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
|
||||
aria-label="Klicken zum Aktivieren der Karte"
|
||||
onclick={activateMap}
|
||||
onkeydown={(e) => e.key === "Enter" && activateMap()}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:cursor-default-click"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
class="block text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { ImageBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: ImageBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const imageSource = $derived(block.image ?? block.img);
|
||||
const imageUrl = $derived(
|
||||
block.resolvedImageSrc ??
|
||||
(typeof imageSource === "string"
|
||||
? imageSource.startsWith("http")
|
||||
? imageSource
|
||||
: null
|
||||
: imageSource && typeof imageSource === "object"
|
||||
? "src" in imageSource && typeof imageSource.src === "string"
|
||||
? imageSource.src
|
||||
: imageSource.file?.url ?? null
|
||||
: null),
|
||||
);
|
||||
const title = $derived(
|
||||
typeof imageSource === "object" && imageSource
|
||||
? ("description" in imageSource && imageSource.description
|
||||
? imageSource.description
|
||||
: "title" in imageSource
|
||||
? (imageSource as { title?: string }).title
|
||||
: undefined)
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
||||
{#if imageUrl}
|
||||
<figure class="max-w-[400px]">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={block.caption ?? title ?? ""}
|
||||
class="w-full rounded-sm"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if block.caption}
|
||||
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Bild nicht verfügbar.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
? (marked.parse(block.description) as string)
|
||||
: "",
|
||||
);
|
||||
const images = $derived(
|
||||
(block.images ?? []).filter((i): i is ImageGalleryImage => i != null && typeof i === "object"),
|
||||
);
|
||||
const withUrl = $derived(
|
||||
images
|
||||
.map((img) => ({ img, url: imageUrl(img) }))
|
||||
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
|
||||
);
|
||||
|
||||
let currentIndex = $state(0);
|
||||
|
||||
function imageUrl(img: ImageGalleryImage): string | null {
|
||||
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
|
||||
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function prev() {
|
||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||
}
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % withUrl.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown max-w-none prose prose-zinc mb-4">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if withUrl.length === 0}
|
||||
<p class="text-sm text-zinc-500">Keine Bilder in der Galerie.</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<img
|
||||
src={withUrl[0].url}
|
||||
alt={withUrl[0].img.description ?? ""}
|
||||
class="w-full rounded-sm object-cover aspect-4/3"
|
||||
loading="eager"
|
||||
/>
|
||||
{#if withUrl[0].img.description}
|
||||
<figcaption class="mt-1 text-sm text-zinc-600">{withUrl[0].img.description}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<div class="relative overflow-hidden rounded-sm bg-zinc-100 aspect-4/3">
|
||||
{#key currentIndex}
|
||||
{@const current = withUrl[currentIndex]}
|
||||
<figure
|
||||
class="m-0 absolute inset-0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.img.description ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading={currentIndex === 0 ? "eager" : "lazy"}
|
||||
/>
|
||||
{#if current.img.description}
|
||||
<figcaption
|
||||
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-sm text-white bg-black/50 rounded-b-sm"
|
||||
>
|
||||
{current.img.description}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Vorheriges Bild"
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Nächstes Bild"
|
||||
onclick={next}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label="Galerieposition">
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label="Bild {i + 1}"
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? "bg-green-600"
|
||||
: "bg-zinc-300 hover:bg-zinc-400"}"
|
||||
onclick={() => (currentIndex = i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { LinkListBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: LinkListBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(
|
||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||
);
|
||||
const links = $derived(block.links ?? []);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="link_list" data-block-slug={block._slug}>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2 font-semibold text-zinc-900">{block.headline}</h3>
|
||||
{/if}
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{#each links as link}
|
||||
{#if typeof link === "object" && link !== null && "url" in link}
|
||||
<li>
|
||||
<a
|
||||
href={(link as { url?: string }).url ?? "#"}
|
||||
class="underline text-green-700 hover:text-green-800"
|
||||
target={(link as { newTab?: boolean }).newTab ? "_blank" : undefined}
|
||||
rel={(link as { newTab?: boolean }).newTab ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{(link as { linkName?: string }).linkName ?? (link as { url?: string }).url}
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<span class="text-zinc-500">{typeof link === "string" ? link : "Link"}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { MarkdownBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.content && typeof block.content === "string"
|
||||
? (marked.parse(block.content) as string)
|
||||
: "",
|
||||
);
|
||||
|
||||
const alignClass = $derived(
|
||||
block.alignment === "center"
|
||||
? "text-center"
|
||||
: block.alignment === "right"
|
||||
? "text-right"
|
||||
: "text-left",
|
||||
);
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={layoutClasses}
|
||||
data-block-type="markdown"
|
||||
data-markdown-id={block.name}
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="markdown max-w-none {alignClass} {className}">
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import PostCard from "../PostCard.svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { PostOverviewBlockData } from "../../lib/block-types";
|
||||
import type { PostEntry } from "../../lib/cms";
|
||||
import { postHref } from "../../lib/blog-utils";
|
||||
|
||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const textHtml = $derived(
|
||||
block.text && typeof block.text === "string"
|
||||
? (marked.parse(block.text) as string)
|
||||
: "",
|
||||
);
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]);
|
||||
const design = $derived(block.design ?? "cards");
|
||||
const tagBase = "/posts/tag";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="post-overview mb-4 {layoutClasses} {className}"
|
||||
data-block-type="post_overview"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if block.headline}
|
||||
<h3 class="mb-2">{block.headline}</h3>
|
||||
{/if}
|
||||
{#if textHtml}
|
||||
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
|
||||
{/if}
|
||||
|
||||
{#if design === "list" && posts.length > 0}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if design === "cards" && posts.length > 0}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each posts as post}
|
||||
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { QuoteBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: QuoteBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const variantClass = $derived(block.variant === "right" ? "text-right" : "text-left");
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
|
||||
<blockquote class="border-l-4 border-green-700 pl-4 {variantClass}">
|
||||
<p class="text-lg italic text-zinc-700">"{block.quote ?? ""}"</p>
|
||||
{#if block.author}
|
||||
<cite class="mt-1 block not-italic text-sm text-zinc-500">— {block.author}</cite>
|
||||
{/if}
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type {
|
||||
SearchableTextBlockData,
|
||||
SearchableTextFragment,
|
||||
} from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import Tag from "../Tag.svelte";
|
||||
import Tags from "../Tags.svelte";
|
||||
|
||||
let { block, class: className = "" }: { block: SearchableTextBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const fragments = $derived.by(() => {
|
||||
const raw = block.textFragments ?? [];
|
||||
return raw
|
||||
.filter((f): f is SearchableTextFragment => typeof f === "object" && f !== null && ("title" in f || "text" in f))
|
||||
.map((f) => {
|
||||
const tagsRaw = f.tags ?? [];
|
||||
const tags = tagsRaw
|
||||
.map((t) => (typeof t === "string" ? t : (t as { name?: string })?.name ?? ""))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
title: (f.title ?? "").trim(),
|
||||
text: (f.text ?? "").trim(),
|
||||
tags,
|
||||
textHtml: (f.text ?? "").trim()
|
||||
? (marked.parse((f.text ?? "").trim()) as string)
|
||||
: "",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const allTags = $derived(
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let selectedTag = $state("all");
|
||||
|
||||
/** Wird im Template aufgerufen, damit Reaktivität auf searchQuery/selectedTag sicher greift. */
|
||||
function getVisibleFragments() {
|
||||
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const tag = selectedTag;
|
||||
return fragments.filter((f) => {
|
||||
const tagMatch = tag === "all" || f.tags.includes(tag);
|
||||
if (!tagMatch) return false;
|
||||
if (q === "") return true;
|
||||
const titleNorm = (f.title ?? "").toLowerCase().replace(/\s+/g, " ");
|
||||
const textNorm = (f.text ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
return titleNorm.includes(q) || textNorm.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
const visibleFragments = $derived(getVisibleFragments());
|
||||
|
||||
const HIGHLIGHT_CLASS = "bg-amber-200 rounded px-0.5";
|
||||
|
||||
/** Suchbegriff in Plain-Text hervorheben (HTML-escaped, Treffer in <mark>). */
|
||||
function highlightInText(text: string, term: string): string {
|
||||
if (!term.trim()) return escapeHtml(text);
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
return escapeHtml(text).replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
}
|
||||
|
||||
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
|
||||
function highlightInHtml(html: string, term: string): string {
|
||||
if (!term.trim()) return html;
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
const parts = html.split(/(<[^>]+>)/g);
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith("<")) return part;
|
||||
return part.replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
? (marked.parse(block.description) as string)
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="searchable-text flex flex-col gap-0 border border-gray-200 bg-slate-50 {layoutClasses} {className}"
|
||||
data-block-type="searchable_text"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="p-4 md:p-6">
|
||||
{#if block.title}
|
||||
<h2 class="text-xl md:text-2xl font-bold bg-slate-50 mb-2">{block.title}</h2>
|
||||
{/if}
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown text-zinc-600 max-w-none prose prose-zinc prose-sm bg-slate-50">{@html descriptionHtml}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-4 md:px-6">
|
||||
<div class="inline-flex bg-amber-100 border border-amber-200/60 text-xs p-3 text-amber-800 items-start gap-2 mb-4 rounded">
|
||||
<span class="shrink-0" aria-hidden="true">ℹ</span>
|
||||
<span>
|
||||
Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-slate-300 bg-white/90 backdrop-blur-sm md:static md:bg-slate-200 md:backdrop-blur-none shadow-md md:shadow-none">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="In Titeln und Inhalten suchen…"
|
||||
class="w-full text-sm px-4 py-2 pr-10 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-transparent bg-white"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label="Suche in Titeln und Inhalten"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-gray-200 text-gray-500"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Suche leeren"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none size-5" aria-hidden="true" />
|
||||
</div>
|
||||
{#if allTags.length > 0}
|
||||
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
|
||||
<div class="flex gap-2 flex-nowrap min-w-max md:flex-wrap md:w-auto">
|
||||
<Tag
|
||||
label="Alle"
|
||||
variant={selectedTag === "all" ? "green" : "white"}
|
||||
active={selectedTag === "all"}
|
||||
onclick={() => (selectedTag = "all")}
|
||||
/>
|
||||
{#each allTags as tag}
|
||||
<Tag
|
||||
label={tag}
|
||||
variant={selectedTag === tag ? "green" : "white"}
|
||||
active={selectedTag === tag}
|
||||
onclick={() => (selectedTag = tag)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-slate-600 px-4 md:px-6 py-2 bg-slate-200 border-b border-slate-300" aria-live="polite">
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? `${visibleFragments.length} Einträge`
|
||||
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0 bg-slate-200">
|
||||
{#each visibleFragments as fragment}
|
||||
<details class="group border-b border-slate-300 last:border-b-0">
|
||||
<summary class="cursor-pointer list-none px-4 md:px-6 py-3 bg-slate-100 hover:bg-slate-50 text-sm font-medium flex items-start gap-2">
|
||||
<div class="grow">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1 justify-end">
|
||||
<Tags tags={fragment.tags} variant="white" />
|
||||
</span>
|
||||
{/if}
|
||||
<Icon icon="mdi:chevron-down" class="shrink-0 text-slate-400 group-open:rotate-180 transition-transform" aria-hidden="true" />
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 py-4 bg-white markdown prose prose-zinc prose-sm max-w-none">
|
||||
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { YoutubeVideoBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const embedUrl = $derived(
|
||||
block.youtubeId
|
||||
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
|
||||
{#if block.youtubeId}
|
||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
|
||||
<iframe
|
||||
title={block.title ?? "YouTube-Video"}
|
||||
src={embedUrl}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
{#if block.description}
|
||||
<p class="mt-1 text-sm text-zinc-600">{block.description}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">YouTube-Video-ID fehlt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
+388
-6
@@ -1,13 +1,340 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import "../styles/global.css";
|
||||
import "../lib/iconify-offline";
|
||||
import Header from "../components/Header.svelte";
|
||||
import HeaderOverlay from "../components/HeaderOverlay.svelte";
|
||||
import Footer from "../components/Footer.svelte";
|
||||
import TopBanner from "../components/TopBanner.svelte";
|
||||
import {
|
||||
getNavigationByKey,
|
||||
NavigationKeys,
|
||||
getPages,
|
||||
getPosts,
|
||||
getFooterBySlug,
|
||||
getFullwidthBannerBySlug,
|
||||
getPageConfigBySlug,
|
||||
} from "../lib/cms";
|
||||
import type { NavLinkEntry, FullwidthBannerEntry } from "../lib/cms";
|
||||
import { resolveImageUrls, ensureTransformedImage } from "../lib/rusty-image";
|
||||
import {
|
||||
DEFAULT_SOCIAL_IMAGE_URL,
|
||||
ROW_RESOLVE,
|
||||
SOCIAL_IMAGE_TRANSFORM,
|
||||
} from "../lib/constants";
|
||||
import { marked } from "marked";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
/** Aufgelöster Banner (Objekt mit image[]) oder Slug (string). */
|
||||
type TopBannerInput = FullwidthBannerEntry | string | null | undefined;
|
||||
|
||||
function isResolvedBanner(v: TopBannerInput): v is FullwidthBannerEntry {
|
||||
return typeof v === "object" && v !== null && "name" in v;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Headline der aktuellen Page (wird im TopBanner angezeigt, falls gepflegt). */
|
||||
headline?: string;
|
||||
/** Subheadline der aktuellen Page (Markdown). */
|
||||
subheadline?: string;
|
||||
/** Fullwidth-Banner: aufgelöst (Objekt mit image[]) bei _resolve, sonst Slug. Nur wenn gesetzt → TopBanner anzeigen. */
|
||||
topFullwidthBanner?: TopBannerInput;
|
||||
/** true = Layout zeigt TopBanner (Titel dort). false = kein TopBanner, Seite zeigt Titel im Content. */
|
||||
showBannerInLayout?: boolean;
|
||||
/** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
const { title = 'RustyAstro', description } = Astro.props;
|
||||
const {
|
||||
title = "RustyAstro",
|
||||
description,
|
||||
headline,
|
||||
subheadline,
|
||||
topFullwidthBanner,
|
||||
showBannerInLayout = false,
|
||||
image: imageProp = null,
|
||||
} = Astro.props;
|
||||
|
||||
// Header: Navigation laden und Links bauen
|
||||
interface NavLink {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
let headerLinks: NavLink[] = [];
|
||||
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
|
||||
function normalizeSlug(s: string | undefined): string {
|
||||
return (s ?? "").replace(/^\//, "").trim();
|
||||
}
|
||||
function getSlugFromEntry(entry: NavLinkEntry): string {
|
||||
if (typeof entry === "string") return entry;
|
||||
const e = entry as { _slug?: string; slug?: string };
|
||||
return e.slug ?? e._slug ?? "";
|
||||
}
|
||||
try {
|
||||
const nav = await getNavigationByKey(NavigationKeys.header, {
|
||||
locale: "de",
|
||||
resolve: "links",
|
||||
});
|
||||
const rawLinks = nav?.links ?? [];
|
||||
const [pages, posts] = await Promise.all([getPages(), getPosts()]);
|
||||
const slugToLabel = new Map<string, string>();
|
||||
/** Für Pages: Nav-Key (_slug oder slug) → kanonischer Slug für URL (ohne führenden Slash). */
|
||||
const slugToPageHref = new Map<string, string>();
|
||||
for (const p of pages) {
|
||||
const label = p.linkName ?? p.name ?? p.headline ?? p.slug ?? p._slug ?? "";
|
||||
const canonicalSlug = normalizeSlug(p.slug ?? p._slug) || "home";
|
||||
const keySlug = normalizeSlug(p.slug);
|
||||
const keyUnderscore = normalizeSlug(p._slug);
|
||||
if (keySlug) {
|
||||
slugToLabel.set(keySlug, label);
|
||||
slugToPageHref.set(keySlug, canonicalSlug);
|
||||
}
|
||||
if (keyUnderscore && keyUnderscore !== keySlug) {
|
||||
slugToLabel.set(keyUnderscore, label);
|
||||
slugToPageHref.set(keyUnderscore, canonicalSlug);
|
||||
}
|
||||
}
|
||||
/** Für Posts: Nav-Key → kanonische URL /post/{slug} (slug = URL). */
|
||||
const slugToPostHref = new Map<string, string>();
|
||||
for (const p of posts) {
|
||||
const label = p.linkName ?? p.headline ?? p.slug ?? p._slug ?? "";
|
||||
const urlSlug = normalizeSlug(p.slug ?? p._slug);
|
||||
const canonical = urlSlug ? `/post/${encodeURIComponent(urlSlug)}` : "";
|
||||
const keySlug = normalizeSlug(p.slug);
|
||||
const keyUnderscore = normalizeSlug(p._slug);
|
||||
if (keySlug) {
|
||||
slugToLabel.set(keySlug, label);
|
||||
slugToPostHref.set(keySlug, canonical);
|
||||
}
|
||||
if (keyUnderscore && keyUnderscore !== keySlug) {
|
||||
slugToLabel.set(keyUnderscore, label);
|
||||
slugToPostHref.set(keyUnderscore, canonical);
|
||||
}
|
||||
}
|
||||
for (const entry of rawLinks) {
|
||||
const asObj =
|
||||
typeof entry === "object" && entry !== null
|
||||
? (entry as { url?: string; linkName?: string; name?: string })
|
||||
: null;
|
||||
if (asObj?.url) {
|
||||
const href =
|
||||
asObj.url.startsWith("/") || asObj.url.startsWith("http")
|
||||
? asObj.url
|
||||
: `/${asObj.url.replace(/^\//, "")}`;
|
||||
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? "";
|
||||
headerLinks.push({ href, label });
|
||||
continue;
|
||||
}
|
||||
const raw = getSlugFromEntry(entry);
|
||||
const slug = normalizeSlug(raw) || "home";
|
||||
const label = slugToLabel.get(slug) ?? slug;
|
||||
const postHref = slugToPostHref.get(slug);
|
||||
const pageSlug = slugToPageHref.get(slug) ?? slug;
|
||||
const hrefSlug = normalizeSlug(pageSlug) || "home";
|
||||
const href =
|
||||
postHref ??
|
||||
(hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`);
|
||||
headerLinks.push({ href, label });
|
||||
}
|
||||
} catch {
|
||||
// CMS nicht erreichbar
|
||||
}
|
||||
|
||||
// Social-Media-Links aus navigation-social-media (icon = iconify string)
|
||||
interface SocialLink {
|
||||
href: string;
|
||||
icon: string;
|
||||
label?: string;
|
||||
}
|
||||
let socialLinks: SocialLink[] = [];
|
||||
try {
|
||||
const socialNav = await getNavigationByKey(NavigationKeys.socialMedia, {
|
||||
locale: "de",
|
||||
resolve: "all",
|
||||
});
|
||||
const rawSocialLinks = socialNav?.links ?? [];
|
||||
for (const entry of rawSocialLinks) {
|
||||
const asObj =
|
||||
typeof entry === "object" && entry !== null
|
||||
? (entry as {
|
||||
url?: string;
|
||||
icon?: string;
|
||||
linkName?: string;
|
||||
name?: string;
|
||||
})
|
||||
: null;
|
||||
if (!asObj?.url) continue;
|
||||
const href =
|
||||
asObj.url.startsWith("/") || asObj.url.startsWith("http")
|
||||
? asObj.url
|
||||
: `/${asObj.url.replace(/^\//, "")}`;
|
||||
const icon = asObj.icon ?? "mdi:link";
|
||||
const label = asObj.linkName ?? asObj.name ?? "";
|
||||
socialLinks.push({ href, icon, label });
|
||||
}
|
||||
} catch {
|
||||
/* Social-Links optional */
|
||||
}
|
||||
|
||||
// Footer laden (mit Row-Resolve)
|
||||
let footerData: Awaited<ReturnType<typeof getFooterBySlug>> = null;
|
||||
try {
|
||||
footerData = await getFooterBySlug("footer-main", {
|
||||
locale: "de",
|
||||
resolve: ROW_RESOLVE,
|
||||
});
|
||||
} catch {
|
||||
// CMS nicht erreichbar
|
||||
}
|
||||
|
||||
// Logo und SEO-Templates aus page_config (wie windwiderstand).
|
||||
let logoUrl: string | null = null;
|
||||
let pageConfig: Awaited<ReturnType<typeof getPageConfigBySlug>> | null = null;
|
||||
try {
|
||||
// Zuerst page-config-default (häufiger Slug), dann default – vermeidet doppelten 404
|
||||
pageConfig =
|
||||
(await getPageConfigBySlug("page-config-default", {
|
||||
locale: "de",
|
||||
resolve: "logo",
|
||||
})) ??
|
||||
(await getPageConfigBySlug("default", { locale: "de", resolve: "logo" }));
|
||||
const rawLogo = pageConfig?.logo;
|
||||
const logoFileUrl =
|
||||
typeof rawLogo === "string" && rawLogo.startsWith("http")
|
||||
? rawLogo
|
||||
: ((
|
||||
rawLogo as
|
||||
| { src?: string; _slug?: string; file?: { url?: string } }
|
||||
| undefined
|
||||
)?.src ??
|
||||
(rawLogo as { _slug?: string; file?: { url?: string } } | undefined)
|
||||
?._slug ??
|
||||
(rawLogo as { file?: { url?: string } } | undefined)?.file?.url);
|
||||
if (logoFileUrl) {
|
||||
const u = logoFileUrl.startsWith("//")
|
||||
? `https:${logoFileUrl}`
|
||||
: logoFileUrl;
|
||||
logoUrl = await ensureTransformedImage(u, {
|
||||
height: 56,
|
||||
fit: "contain",
|
||||
format: "webp",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* Logo optional */
|
||||
}
|
||||
|
||||
// SEO Title/Description: Page/Post-Wert + page_config-Template ($1 = Platzhalter, wie windwiderstand)
|
||||
let seoTitle = title ?? "RustyAstro";
|
||||
let seoDescription = description ?? "";
|
||||
if (pageConfig?.seoTitle)
|
||||
seoTitle = pageConfig.seoTitle.replace(/\$1/g, seoTitle);
|
||||
if (pageConfig?.seoDescription)
|
||||
seoDescription = pageConfig.seoDescription.replace(/\$1/g, seoDescription);
|
||||
|
||||
// SEO: canonical und Social-Image (nach logoUrl)
|
||||
// Lokale Entwicklung: echte Origin (localhost:4321); sonst PUBLIC_SITE_URL / site aus Config
|
||||
const siteFromEnv = (import.meta.env.SITE || "").replace(/\/$/, "");
|
||||
const isLocalhost =
|
||||
Astro.url.origin.startsWith("http://localhost") ||
|
||||
Astro.url.origin.startsWith("http://127.0.0.1");
|
||||
const baseUrl = isLocalhost ? Astro.url.origin : (siteFromEnv || Astro.url.origin);
|
||||
const pathname = Astro.url.pathname || "/";
|
||||
const canonical = `${baseUrl}${pathname}`;
|
||||
|
||||
// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen)
|
||||
const socialImageSource = imageProp ?? logoUrl ?? null;
|
||||
const needsTransform =
|
||||
!socialImageSource ||
|
||||
socialImageSource.startsWith("http://") ||
|
||||
socialImageSource.startsWith("https://");
|
||||
const urlToTransform = socialImageSource ?? DEFAULT_SOCIAL_IMAGE_URL;
|
||||
let socialImageResolved: string | null = null;
|
||||
if (needsTransform) {
|
||||
try {
|
||||
const transformed = await ensureTransformedImage(
|
||||
urlToTransform,
|
||||
SOCIAL_IMAGE_TRANSFORM,
|
||||
);
|
||||
socialImageResolved = transformed.startsWith("/")
|
||||
? `${baseUrl}${transformed}`
|
||||
: transformed;
|
||||
} catch {
|
||||
socialImageResolved = null;
|
||||
}
|
||||
} else {
|
||||
// Bereits lokaler Pfad (z. B. von Post), nur baseUrl davor
|
||||
socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`;
|
||||
}
|
||||
const socialImage = socialImageResolved ?? "";
|
||||
|
||||
// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner)
|
||||
let topBanner: FullwidthBannerEntry | null = null;
|
||||
let topBannerResolvedImages: string[] = [];
|
||||
if (
|
||||
showBannerInLayout &&
|
||||
topFullwidthBanner != null &&
|
||||
(typeof topFullwidthBanner === "string" ? topFullwidthBanner !== "" : true)
|
||||
) {
|
||||
try {
|
||||
if (isResolvedBanner(topFullwidthBanner)) {
|
||||
topBanner = topFullwidthBanner;
|
||||
const bannerImages = Array.isArray(topBanner.image)
|
||||
? topBanner.image
|
||||
: topBanner.image != null
|
||||
? [topBanner.image]
|
||||
: [];
|
||||
if (bannerImages.length > 0) {
|
||||
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
|
||||
width: 2000,
|
||||
height: 750,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
typeof topFullwidthBanner === "string" &&
|
||||
topFullwidthBanner !== ""
|
||||
) {
|
||||
topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, {
|
||||
locale: "de",
|
||||
});
|
||||
const bannerImages = Array.isArray(topBanner?.image)
|
||||
? topBanner.image
|
||||
: topBanner?.image != null
|
||||
? [topBanner.image]
|
||||
: [];
|
||||
if (bannerImages.length > 0) {
|
||||
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
|
||||
width: 2000,
|
||||
height: 750,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CMS nicht erreichbar oder kein Banner
|
||||
}
|
||||
}
|
||||
|
||||
// Titelblock (Headline/Subheadline als Markdown), wenn kein Banner angezeigt wird
|
||||
const showTitleInContent =
|
||||
!showBannerInLayout &&
|
||||
((headline != null && headline !== "") ||
|
||||
(subheadline != null && subheadline !== ""));
|
||||
const titleHeadlineHtml =
|
||||
showTitleInContent && headline?.trim()
|
||||
? (marked.parseInline(headline) as string)
|
||||
: "";
|
||||
const titleSubheadlineHtml =
|
||||
showTitleInContent && subheadline?.trim()
|
||||
? (marked.parseInline(subheadline) as string)
|
||||
: "";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -16,10 +343,65 @@ const { title = 'RustyAstro', description } = Astro.props;
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<title>{seoTitle}</title>
|
||||
{seoDescription && <meta name="description" content={seoDescription} />}
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:title" content={seoTitle} />
|
||||
{
|
||||
seoDescription && (
|
||||
<meta property="og:description" content={seoDescription} />
|
||||
)
|
||||
}
|
||||
{socialImage && <meta property="og:image" content={socialImage} />}
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={canonical} />
|
||||
<meta name="twitter:title" content={seoTitle} />
|
||||
{
|
||||
seoDescription && (
|
||||
<meta name="twitter:description" content={seoDescription} />
|
||||
)
|
||||
}
|
||||
{socialImage && <meta name="twitter:image" content={socialImage} />}
|
||||
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<slot />
|
||||
<body class="flex min-h-screen flex-col text-zinc-900">
|
||||
<Header
|
||||
links={headerLinks}
|
||||
socialLinks={socialLinks}
|
||||
logoUrl={logoUrl}
|
||||
client:load
|
||||
/>
|
||||
<HeaderOverlay client:load />
|
||||
{
|
||||
showBannerInLayout &&
|
||||
(topBanner != null || topBannerResolvedImages.length > 0) && (
|
||||
<TopBanner
|
||||
banner={topBanner}
|
||||
resolvedImages={topBannerResolvedImages}
|
||||
headline={headline}
|
||||
subheadline={subheadline}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<main class="flex-1 min-h-[60em]">
|
||||
<div class="container-custom pb-12">
|
||||
{
|
||||
showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
{titleHeadlineHtml && <h1 set:html={titleHeadlineHtml} />}
|
||||
{titleSubheadlineHtml && <h2 set:html={titleSubheadlineHtml} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<Footer footer={footerData} />
|
||||
<script src="/pagefind/pagefind-ui.js" is:inline />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Layout für Content-Blöcke (z. B. Markdown): Grid-Spalten (1–12) und Abstand.
|
||||
* Entspricht component_layout im CMS (desktop/tablet/mobile = Breite in 12tel, spaceBottom = rem).
|
||||
*/
|
||||
|
||||
export type BlockLayout = {
|
||||
/** Breite auf Desktop (1–12), z. B. "8" = 8/12. */
|
||||
desktop?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Breite auf Tablet (1–12). */
|
||||
tablet?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Breite auf Mobile (1–12), Default 12. */
|
||||
mobile?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
|
||||
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
|
||||
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
|
||||
breakout?: boolean;
|
||||
};
|
||||
|
||||
const COL_SPAN: Record<string, string> = {
|
||||
"1": "col-span-1",
|
||||
"2": "col-span-2",
|
||||
"3": "col-span-3",
|
||||
"4": "col-span-4",
|
||||
"5": "col-span-5",
|
||||
"6": "col-span-6",
|
||||
"7": "col-span-7",
|
||||
"8": "col-span-8",
|
||||
"9": "col-span-9",
|
||||
"10": "col-span-10",
|
||||
"11": "col-span-11",
|
||||
"12": "col-span-12",
|
||||
};
|
||||
|
||||
const MD_COL_SPAN: Record<string, string> = {
|
||||
"1": "md:col-span-1",
|
||||
"2": "md:col-span-2",
|
||||
"3": "md:col-span-3",
|
||||
"4": "md:col-span-4",
|
||||
"5": "md:col-span-5",
|
||||
"6": "md:col-span-6",
|
||||
"7": "md:col-span-7",
|
||||
"8": "md:col-span-8",
|
||||
"9": "md:col-span-9",
|
||||
"10": "md:col-span-10",
|
||||
"11": "md:col-span-11",
|
||||
"12": "md:col-span-12",
|
||||
};
|
||||
|
||||
const LG_COL_SPAN: Record<string, string> = {
|
||||
"1": "lg:col-span-1",
|
||||
"2": "lg:col-span-2",
|
||||
"3": "lg:col-span-3",
|
||||
"4": "lg:col-span-4",
|
||||
"5": "lg:col-span-5",
|
||||
"6": "lg:col-span-6",
|
||||
"7": "lg:col-span-7",
|
||||
"8": "lg:col-span-8",
|
||||
"9": "lg:col-span-9",
|
||||
"10": "lg:col-span-10",
|
||||
"11": "lg:col-span-11",
|
||||
"12": "lg:col-span-12",
|
||||
};
|
||||
|
||||
const SPACE_BOTTOM: Record<number, string> = {
|
||||
0: "mb-0",
|
||||
0.5: "mb-2", // 0.5rem
|
||||
1: "mb-4", // 1rem
|
||||
1.5: "mb-6", // 1.5rem
|
||||
2: "mb-8", // 2rem
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
|
||||
* Parent muss grid grid-cols-12 haben.
|
||||
*/
|
||||
/**
|
||||
* Gibt Tailwind-Klassen für den Block-Inhalt zurück.
|
||||
* Bei breakout: true nur w-full + Abstand (Spalten/Wrapper kommen von außen).
|
||||
*/
|
||||
export function getBlockLayoutClasses(
|
||||
layout: BlockLayout | undefined | null,
|
||||
): string {
|
||||
if (!layout || typeof layout !== "object") {
|
||||
return "col-span-12 mb-4";
|
||||
}
|
||||
const breakout = layout.breakout === true;
|
||||
const sb =
|
||||
typeof layout.spaceBottom === "number"
|
||||
? layout.spaceBottom
|
||||
: Number(layout.spaceBottom);
|
||||
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||
|
||||
if (breakout) {
|
||||
return `w-full ${spaceClass}`;
|
||||
}
|
||||
|
||||
const mobile = String(layout.mobile ?? "12");
|
||||
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
|
||||
const desktop = layout.desktop != null ? String(layout.desktop) : undefined;
|
||||
|
||||
const parts: string[] = [
|
||||
COL_SPAN[mobile] ?? "col-span-12",
|
||||
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
|
||||
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
|
||||
spaceClass,
|
||||
];
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** Prüft, ob das Layout breakout (fullwidth) haben soll. */
|
||||
export function isBlockLayoutBreakout(
|
||||
layout: BlockLayout | undefined | null,
|
||||
): boolean {
|
||||
return !!(layout && typeof layout === "object" && layout.breakout === true);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
|
||||
* Wird von Astro-Seiten und Svelte-Komponenten genutzt.
|
||||
*/
|
||||
|
||||
import type { BlockLayout } from "./block-layout";
|
||||
|
||||
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
|
||||
export type ResolvedBlock = {
|
||||
_type?: string;
|
||||
_slug?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** Gemeinsames Row-Layout (page, post, footer erben content_layout). */
|
||||
export interface RowContentLayout {
|
||||
row1Content?: unknown[];
|
||||
row1JustifyContent?: string;
|
||||
row1AlignItems?: string;
|
||||
row2Content?: unknown[];
|
||||
row2JustifyContent?: string;
|
||||
row2AlignItems?: string;
|
||||
row3Content?: unknown[];
|
||||
row3JustifyContent?: string;
|
||||
row3AlignItems?: string;
|
||||
}
|
||||
|
||||
/** Resolved Markdown-Block vom CMS (_type: "markdown"). */
|
||||
export interface MarkdownBlockData {
|
||||
_type?: "markdown";
|
||||
_slug?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
alignment?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Headline (_type: "headline"). */
|
||||
export interface HeadlineBlockData {
|
||||
_type?: "headline";
|
||||
_slug?: string;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
text?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** HTML (_type: "html"). */
|
||||
export interface HtmlBlockData {
|
||||
_type?: "html";
|
||||
_slug?: string;
|
||||
html?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Iframe (_type: "iframe"). */
|
||||
export interface IframeBlockData {
|
||||
_type?: "iframe";
|
||||
_slug?: string;
|
||||
iframe?: string;
|
||||
content?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Image (_type: "image"). image oder img: Slug, URL-String oder aufgelöstes Objekt (src/description oder file.url). */
|
||||
export interface ImageBlockData {
|
||||
_type?: "image";
|
||||
_slug?: string;
|
||||
image?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
/** Alternative zu image (manche CMS-Responses liefern img). */
|
||||
img?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
|
||||
resolvedImageSrc?: string;
|
||||
caption?: string;
|
||||
aspectRatio?: number;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Eintrag in einer image_gallery (img mit src/description). */
|
||||
export interface ImageGalleryImage {
|
||||
_type?: "img";
|
||||
_slug?: string;
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
}
|
||||
|
||||
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
|
||||
export interface ImageGalleryBlockData {
|
||||
_type?: "image_gallery";
|
||||
_slug?: string;
|
||||
/** Optionaler Einleitungstext (Markdown). */
|
||||
description?: string;
|
||||
images?: ImageGalleryImage[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** YouTube-Video (_type: "youtube_video"). */
|
||||
export interface YoutubeVideoBlockData {
|
||||
_type?: "youtube_video";
|
||||
_slug?: string;
|
||||
youtubeId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
params?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Zitat (_type: "quote"). */
|
||||
export interface QuoteBlockData {
|
||||
_type?: "quote";
|
||||
_slug?: string;
|
||||
quote?: string;
|
||||
author?: string;
|
||||
variant?: "left" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Link-Liste (_type: "link_list"). links können Slugs oder aufgelöste link-Objekte sein. */
|
||||
export interface LinkListBlockData {
|
||||
_type?: "link_list";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Post-Übersicht (_type: "post_overview"). Nach resolvePostOverviewBlocks: postsResolved gesetzt. */
|
||||
export interface PostOverviewBlockData {
|
||||
_type?: "post_overview";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** Einleitungstext (Markdown). */
|
||||
text?: string;
|
||||
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
|
||||
allPosts?: boolean;
|
||||
/** Nur wenn allPosts false: feste Post-Liste (Slugs oder aufgelöste Einträge). */
|
||||
posts?: unknown[];
|
||||
/** Tag-Slugs, nach denen gefiltert wird (nur bei allPosts true). */
|
||||
filterByTag?: string[];
|
||||
/** Max. Anzahl Einträge. */
|
||||
numberItems?: number;
|
||||
/** "list" | "cards". */
|
||||
design?: "list" | "cards";
|
||||
layout?: BlockLayout;
|
||||
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
|
||||
postsResolved?: unknown[];
|
||||
}
|
||||
|
||||
/** Tag-Referenz (API liefert oft { _slug, _type, name }). */
|
||||
export type SearchableTextTagRef = string | { _slug?: string; name?: string };
|
||||
|
||||
/** Ein Text-Fragment (aus text_fragment), für SearchableTextBlock. */
|
||||
export interface SearchableTextFragment {
|
||||
_slug?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
tags?: SearchableTextTagRef[];
|
||||
}
|
||||
|
||||
/** Durchsuchbarer Text (_type: "searchable_text"). textFragments können Slugs oder aufgelöste Objekte sein. */
|
||||
export interface SearchableTextBlockData {
|
||||
_type?: "searchable_text";
|
||||
_slug?: string;
|
||||
/** Titel der Suchkomponente. */
|
||||
title?: string;
|
||||
/** Einleitung (Markdown). */
|
||||
description?: string;
|
||||
/** Slugs oder aufgelöste text_fragment-Einträge. */
|
||||
textFragments?: (string | SearchableTextFragment)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { PostEntry } from "./cms";
|
||||
import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug } from "./cms";
|
||||
import type { RowContentLayout } from "./block-types";
|
||||
/** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */
|
||||
|
||||
/** Slug → Anzeigename (name) aus der Tag-API. Für Auflösung von post.postTag in Lesbarkeit. */
|
||||
export async function getTagsMap(): Promise<Map<string, string>> {
|
||||
const tags = await getTags();
|
||||
const m = new Map<string, string>();
|
||||
for (const t of tags) {
|
||||
const slug =
|
||||
(t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? "";
|
||||
if (slug) m.set(slug, (t as { name?: string }).name ?? slug);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Setzt post.postTag auf { _slug, name }[]; bei reinen Slug-Strings wird name aus slugToName geholt. */
|
||||
export function resolvePostTagsInPost(
|
||||
post: PostEntry,
|
||||
slugToName: Map<string, string>,
|
||||
): void {
|
||||
const raw = post.postTag ?? [];
|
||||
if (!Array.isArray(raw)) return;
|
||||
(post as { postTag?: { _slug: string; name: string }[] }).postTag = raw.map(
|
||||
(t) => {
|
||||
if (typeof t === "string") {
|
||||
return { _slug: t, name: slugToName.get(t) ?? t };
|
||||
}
|
||||
const o = t as { _slug?: string; name?: string };
|
||||
const slug = o._slug ?? o?.name ?? "";
|
||||
return { _slug: slug, name: o?.name ?? slugToName.get(slug) ?? slug };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const POSTS_PER_PAGE = 10;
|
||||
|
||||
export function getPostsPerPage(): number {
|
||||
return POSTS_PER_PAGE;
|
||||
}
|
||||
|
||||
/** Sortiert Posts nach Datum (neueste zuerst). */
|
||||
export function sortPostsByDate(posts: PostEntry[]): PostEntry[] {
|
||||
return [...posts].sort((a, b) => {
|
||||
const da = a.created ?? a.date ?? "";
|
||||
const db = b.created ?? b.date ?? "";
|
||||
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);
|
||||
}
|
||||
|
||||
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 → Anzeigename.
|
||||
* Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug].astro).
|
||||
*/
|
||||
export async function resolvePostOverviewBlocks(
|
||||
layout: RowContentLayout | null | undefined,
|
||||
): Promise<Map<string, string>> {
|
||||
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 = 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 { ensureTransformedImage } = await import("./rusty-image");
|
||||
const url = await ensureTransformedImage(raw, {
|
||||
width: 150,
|
||||
height: 200,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
});
|
||||
(
|
||||
post as PostEntry & { _resolvedImageUrl?: string }
|
||||
)._resolvedImageUrl = url;
|
||||
} catch {
|
||||
// Bild optional
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tagsMap;
|
||||
}
|
||||
|
||||
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 slugToName für Slug → Name; Objekte mit name/_slug werden unterstützt.
|
||||
*/
|
||||
function resolveFragmentTags(
|
||||
tagsRaw: unknown[] | undefined,
|
||||
slugToName: Map<string, string>,
|
||||
): string[] {
|
||||
if (!Array.isArray(tagsRaw)) return [];
|
||||
return tagsRaw
|
||||
.map((t) => {
|
||||
if (typeof t === "string") return slugToName.get(t) ?? t;
|
||||
const o = t as { _slug?: string; name?: string };
|
||||
const slug = o._slug ?? "";
|
||||
return (o.name ?? slugToName.get(slug) ?? 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 slugToName Optionale Map Slug → Anzeigename (z. B. von resolvePostOverviewBlocks); sonst getTagsMap().
|
||||
*/
|
||||
export async function resolveSearchableTextBlocks(
|
||||
layout: RowContentLayout | null | undefined,
|
||||
slugToName?: Map<string, string>,
|
||||
): Promise<void> {
|
||||
if (!layout) return;
|
||||
const tagsMap = slugToName ?? (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7443
-71
File diff suppressed because it is too large
Load Diff
+457
-15
@@ -2,6 +2,7 @@
|
||||
* RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
|
||||
* Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
|
||||
*
|
||||
* Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests).
|
||||
* Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec.
|
||||
* Nach Schema-Änderungen im CMS: `yarn generate:api-types` (RustyCMS muss laufen).
|
||||
*/
|
||||
@@ -10,7 +11,9 @@ import type { components, operations } from "./cms-api.generated";
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
const base = (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||
const base = (
|
||||
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
return base;
|
||||
};
|
||||
|
||||
@@ -18,7 +21,16 @@ const getBaseUrl = (): string => {
|
||||
export type PageEntry = components["schemas"]["page"];
|
||||
|
||||
/** Antwort von GET /api/content/page (aus OpenAPI-Spec generiert). */
|
||||
export type PageListResponse = operations["listPage"]["responses"][200]["content"]["application/json"];
|
||||
export type PageListResponse =
|
||||
operations["listPage"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
/** Page-Config (Site-Einstellungen, wie in windwiderstand). Optional: homePage für Startseiten-Slug. */
|
||||
export type PageConfigEntry = components["schemas"]["page_config"] & {
|
||||
/** Referenz auf die Page für die Startseite (Slug oder { _slug }). */
|
||||
homePage?: string | { _slug?: string };
|
||||
};
|
||||
export type PageConfigListResponse =
|
||||
operations["listPageConfig"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
export type OpenApiSpec = {
|
||||
openapi: string;
|
||||
@@ -29,6 +41,9 @@ export type OpenApiSpec = {
|
||||
|
||||
let openApiCache: OpenApiSpec | null = null;
|
||||
|
||||
/** Einfacher Request-Cache: gleiche Aufrufe (pro Prozess) nur einmal ausführen. Reduziert doppelte CMS-Requests bei Layout + Page. */
|
||||
const listCache = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
|
||||
* Wird gecacht (einmal pro Request/Build).
|
||||
@@ -39,7 +54,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const spec = (await res.json()) as OpenApiSpec;
|
||||
@@ -48,37 +63,464 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Page-Einträge (GET /api/content/page).
|
||||
* Alle Page-Einträge (GET /api/content/page). Gecacht pro Prozess.
|
||||
*/
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
const key = "getPages";
|
||||
let p = listCache.get(key) as Promise<PageEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as PageListResponse;
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle page_config-Einträge (GET /api/content/page_config).
|
||||
* Wie in windwiderstand: eine Config (z. B. slug "default") mit u. a. homePage.
|
||||
*/
|
||||
export async function getPageConfigs(options?: {
|
||||
locale?: string;
|
||||
per_page?: number;
|
||||
}): Promise<PageConfigEntry[]> {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
const url = new URL(`${base}/api/content/page_config`);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
url.searchParams.set("_per_page", String(options?.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as PageListResponse;
|
||||
return data.items ?? [];
|
||||
const data = (await res.json()) as PageConfigListResponse;
|
||||
return (data.items ?? []) as PageConfigEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-Config anhand Slug (z. B. "default"). Gecacht pro Prozess (Key: slug + options).
|
||||
*/
|
||||
export async function getPageConfigBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string; resolve?: string },
|
||||
): Promise<PageConfigEntry | null> {
|
||||
const opts = options ?? {};
|
||||
const key = `getPageConfigBySlug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/page_config/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get page_config failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as PageConfigEntry;
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startseiten-Slug aus page_config (wie windwiderstand).
|
||||
* Versucht zuerst page-config-default, dann default, sonst erstes Config-Item aus der Liste.
|
||||
* Liefert homePage als Slug (String oder ref._slug), sonst null → Fallback auf Konstante.
|
||||
*/
|
||||
export async function getHomePageSlugFromConfig(options?: {
|
||||
locale?: string;
|
||||
}): Promise<string | null> {
|
||||
const config =
|
||||
(await getPageConfigBySlug("page-config-default", options)) ??
|
||||
(await getPageConfigBySlug("default", options)) ??
|
||||
(await getPageConfigs({ locale: options?.locale, per_page: 1 }))[0] ??
|
||||
null;
|
||||
if (!config?.homePage) return null;
|
||||
const raw = config.homePage;
|
||||
if (typeof raw === "string") return raw.trim() || null;
|
||||
const slug = (raw as { _slug?: string })?._slug?.trim();
|
||||
return slug ?? null;
|
||||
}
|
||||
|
||||
/** Optionen für Content-Get (z. B. _resolve, _locale). */
|
||||
export type ContentGetOptions = {
|
||||
locale?: string;
|
||||
/** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
|
||||
resolve?: string[];
|
||||
};
|
||||
|
||||
/** Führenden Slash entfernen (CMS kann "slug": "/about" liefern). */
|
||||
function normalizePageSlug(s: string | undefined): string {
|
||||
return (s ?? "").replace(/^\//, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
|
||||
* Die API erwartet oft _slug (z. B. "page-about"); die URL nutzt "about".
|
||||
* Versucht: slug, "/" + slug, dann Lookup in der Liste und Abruf per _slug.
|
||||
*/
|
||||
export async function getPageBySlug(slug: string): Promise<PageEntry | null> {
|
||||
export async function getPageBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PageEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page/${encodeURIComponent(slug)}`);
|
||||
const trySlug = (s: string): Promise<Response> => {
|
||||
const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
return fetch(u.toString());
|
||||
};
|
||||
let res = await trySlug(slug);
|
||||
if (res.status === 404 && !slug.startsWith("/")) {
|
||||
res = await trySlug("/" + slug);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
const items = await getPages();
|
||||
const norm = normalizePageSlug(slug);
|
||||
const match = items.find(
|
||||
(p) =>
|
||||
normalizePageSlug(p.slug) === norm ||
|
||||
normalizePageSlug(p._slug) === norm ||
|
||||
p._slug === slug ||
|
||||
p.slug === slug,
|
||||
);
|
||||
if (match?._slug) {
|
||||
res = await trySlug(match._slug);
|
||||
}
|
||||
}
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`RustyCMS get page failed: ${res.status} for slug "${slug}".`);
|
||||
throw new Error(
|
||||
`RustyCMS get page failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as PageEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Slugs für Page (für getStaticPaths / generate).
|
||||
*/
|
||||
/** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
|
||||
export async function getPageSlugs(): Promise<string[]> {
|
||||
const items = await getPages();
|
||||
return items.map((p) => p._slug ?? p.slug ?? "").filter(Boolean);
|
||||
return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). Nach Schema-Änderung: `yarn generate:api-types` mit laufendem RustyCMS. */
|
||||
export type NavigationEntry = components["schemas"]["navigation"];
|
||||
|
||||
/** Ein Eintrag aus navigation.links (Referenz { _slug, _type } oder Slug-String). */
|
||||
export type NavLinkEntry = NavigationEntry["links"] extends (infer L)[]
|
||||
? L
|
||||
: never;
|
||||
|
||||
/** Antwort von GET /api/content/navigation (paginiert). */
|
||||
export type NavigationListResponse =
|
||||
operations["listNavigation"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
/**
|
||||
* Alle Navigation-Einträge (GET /api/content/navigation). Gecacht pro Prozess (Key aus options).
|
||||
*/
|
||||
export async function getNavigations(options?: {
|
||||
locale?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
resolve?: string;
|
||||
}): Promise<{
|
||||
items: NavigationEntry[];
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}> {
|
||||
const opts = options ?? {};
|
||||
const key = `getNavigations:${opts.locale ?? ""}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as
|
||||
| Promise<{
|
||||
items: NavigationEntry[];
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}>
|
||||
| undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/navigation`);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
url.searchParams.set("_page", String(opts.page ?? 1));
|
||||
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as NavigationListResponse;
|
||||
return {
|
||||
items: data.items ?? [],
|
||||
page: data.page ?? 1,
|
||||
per_page: data.per_page ?? 50,
|
||||
total: data.total ?? 0,
|
||||
total_pages: data.total_pages ?? 1,
|
||||
};
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Keys für Navigation wie bei windwiderstand (CF_Navigation_Keys). */
|
||||
export const NavigationKeys = {
|
||||
header: "navigation-header",
|
||||
socialMedia: "navigation-social-media",
|
||||
footer: "navigation-footer",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Navigation anhand Key/Slug aus der Liste holen (wie getNavigationByKey in windwiderstand).
|
||||
* Mit options.resolve (z. B. "links") werden links aufgelöst (Link-Einträge mit url, linkName).
|
||||
*/
|
||||
export async function getNavigationByKey(
|
||||
key: string,
|
||||
options?: { locale?: string; resolve?: string },
|
||||
): Promise<NavigationEntry | null> {
|
||||
const { items } = await getNavigations({
|
||||
locale: options?.locale ?? "de",
|
||||
per_page: 50,
|
||||
resolve: options?.resolve,
|
||||
});
|
||||
const entry = items.find(
|
||||
(n) =>
|
||||
(n as { _slug?: string; internal?: string })._slug === key ||
|
||||
(n as { _slug?: string; internal?: string }).internal === key,
|
||||
);
|
||||
return entry ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation anhand des Slugs holen (z. B. "header").
|
||||
* Zuerst Versuch über Listen-Endpunkt (getNavigationByKey), falls gewünscht nur Liste:
|
||||
* GET /api/content/navigation?_page=1&_per_page=50&_locale=de, dann Eintrag mit _slug/internal finden.
|
||||
* Optional: GET /api/content/navigation/:slug falls vom Backend unterstützt.
|
||||
*/
|
||||
export async function getNavigationBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<NavigationEntry | null> {
|
||||
const byKey = await getNavigationByKey(slug, options);
|
||||
if (byKey) return byKey;
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NavigationEntry;
|
||||
}
|
||||
|
||||
/** Footer (Single Source of Truth: RustyCMS OpenAPI). */
|
||||
export type FooterEntry = components["schemas"]["footer"];
|
||||
|
||||
/**
|
||||
* Footer anhand des Slugs holen (z. B. "default").
|
||||
* content/de/footer/default.json5 → slug "default", locale "de".
|
||||
*/
|
||||
export async function getFooterBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<FooterEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/footer/${encodeURIComponent(slug)}`);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
url.searchParams.set("_resolve", options.resolve.join(","));
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get footer failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as FooterEntry;
|
||||
}
|
||||
|
||||
/** Post-Eintrag (Single Source of Truth: RustyCMS OpenAPI). */
|
||||
export type PostEntry = components["schemas"]["post"];
|
||||
|
||||
/**
|
||||
* Post anhand des Slugs holen (GET /api/content/post/:slug).
|
||||
*/
|
||||
/** Normalisiert Post-Slug für URL (führenden Slash entfernen). */
|
||||
function normalizePostSlug(s: string | undefined): string {
|
||||
return (s ?? "").replace(/^\//, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post nach Slug (GET /api/content/post/:slug).
|
||||
* Die API erwartet oft _slug; die URL nutzt post.slug. Bei 404: Liste laden und per _slug nachladen.
|
||||
*/
|
||||
export async function getPostBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PostEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = (s: string): Promise<Response> => {
|
||||
const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
return fetch(u.toString());
|
||||
};
|
||||
let res = await trySlug(slug);
|
||||
if (res.status === 404 && !slug.startsWith("/")) {
|
||||
res = await trySlug("/" + slug);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
const items = await getPosts();
|
||||
const norm = normalizePostSlug(slug);
|
||||
const match = items.find(
|
||||
(p) =>
|
||||
normalizePostSlug(p.slug) === norm ||
|
||||
normalizePostSlug(p._slug) === norm ||
|
||||
p._slug === slug ||
|
||||
p.slug === slug,
|
||||
);
|
||||
if (match?._slug) {
|
||||
res = await trySlug(match._slug);
|
||||
}
|
||||
}
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get post failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as PostEntry;
|
||||
}
|
||||
|
||||
/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
|
||||
export async function getPostSlugs(): Promise<string[]> {
|
||||
const items = await getPosts();
|
||||
return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve). */
|
||||
export interface GetPostsOptions {
|
||||
_sort?: string;
|
||||
_order?: "asc" | "desc";
|
||||
/** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
|
||||
resolve?: string | string[];
|
||||
}
|
||||
|
||||
/** Alle Post-Einträge (GET /api/content/post). Gecacht pro Prozess (Key aus options). */
|
||||
export async function getPosts(
|
||||
options?: GetPostsOptions,
|
||||
): Promise<PostEntry[]> {
|
||||
const opts = options ?? {};
|
||||
const resolveVal = Array.isArray(opts.resolve)
|
||||
? opts.resolve.join(",")
|
||||
: (opts.resolve ?? "");
|
||||
const key = `getPosts:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
|
||||
let p = listCache.get(key) as Promise<PostEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/post`);
|
||||
if (opts._sort) url.searchParams.set("_sort", opts._sort);
|
||||
if (opts._order) url.searchParams.set("_order", opts._order);
|
||||
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||
const data = (await res.json()) as { items?: PostEntry[] };
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Tag-Eintrag (z. B. für Blog-Filter). */
|
||||
export type TagEntry = components["schemas"]["tag"];
|
||||
|
||||
/** Alle Tags (GET /api/content/tag). Gecacht pro Prozess. */
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
const key = "getTags";
|
||||
let p = listCache.get(key) as Promise<TagEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/tag`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TagEntry[] };
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Text-Fragment (z. B. für searchable_text). */
|
||||
export type TextFragmentEntry = components["schemas"]["text_fragment"];
|
||||
|
||||
/** Text-Fragment anhand Slug (GET /api/content/text_fragment/:slug). */
|
||||
export async function getTextFragmentBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<TextFragmentEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get text_fragment failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as TextFragmentEntry;
|
||||
}
|
||||
|
||||
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
|
||||
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];
|
||||
|
||||
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
|
||||
export async function getFullwidthBannerBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<FullwidthBannerEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get fullwidth_banner failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as FullwidthBannerEntry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Zentrale Konstanten für die Anwendung.
|
||||
* URLs, CMS-Resolve-Arrays und Fallback-Werte.
|
||||
*/
|
||||
|
||||
/** Platzhalter-Bild für og:image / twitter:image, wenn weder Page/Post-Bild noch Logo gesetzt ist. */
|
||||
export const DEFAULT_SOCIAL_IMAGE_URL =
|
||||
"https://images.ctfassets.net/xjxq6v7l1pfe/43yZHpF9OCnrBlEWHJSZMK/1ef20b265ea1e4e55317812ee5ae6b4c/simple-green-tree-illustrated-watercolor-with-gentle-shading-great-rural-scenery-farm-backdrops-naturethemed-designs-landsca.jpg";
|
||||
|
||||
/** RustyImage-Parameter für og:image / twitter:image (immer über unser Transform). */
|
||||
export const SOCIAL_IMAGE_TRANSFORM = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: "cover" as const,
|
||||
format: "webp" as const,
|
||||
};
|
||||
|
||||
/** CMS Content-Rows für resolve (Footer, Pages, Posts). */
|
||||
export const ROW_RESOLVE: string[] = [
|
||||
"row1Content",
|
||||
"row2Content",
|
||||
"row3Content",
|
||||
];
|
||||
|
||||
/** Resolve für Page-Requests: "all" = alle Referenzen inkl. verschachtelt (z. B. image_gallery.images). */
|
||||
export const PAGE_RESOLVE = ["all"];
|
||||
|
||||
/** Resolve für Post-Requests (Rows + Post-Bild, Tags). */
|
||||
export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
|
||||
|
||||
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
|
||||
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Iconify Offline: MDI-Collection für Server und Client registrieren.
|
||||
* Muss an zwei Stellen importiert werden (Side-Effect-Import), damit
|
||||
* <Icon icon="mdi:…" /> überall gleich rendert (kein Hydration-Mismatch):
|
||||
* 1. Layout.astro (--- Block) → läuft beim SSR
|
||||
* 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser
|
||||
* Danach können alle Svelte-Komponenten Icon ohne API nutzen.
|
||||
*/
|
||||
import { addCollection } from "@iconify/svelte";
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi";
|
||||
|
||||
addCollection(mdiIcons);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** true = Backdrop über Content anzeigen (Menü oder Suche offen). */
|
||||
export const overlayOpen = writable(false);
|
||||
|
||||
/** Callback zum Schließen (setzt im Header menuOpen/searchOpen auf false). */
|
||||
export const overlayClose = writable<(() => void) | null>(null);
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* RustyImage: Bilder über RustyCMS /api/transform laden, in public speichern, lokal ausliefern.
|
||||
* Hash aus URL + Transform-Parametern, damit gleiche Anfrage nicht erneut geladen wird.
|
||||
* Nur für Build/SSR (Node) – nutzt fs und fetch zur CMS-Basis-URL.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** Transform-Parameter wie von der RustyCMS-API unterstützt. */
|
||||
export interface RustyImageTransformParams {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Aspect ratio vor Skalierung, z. B. "1:1" oder "16:9" (Mittelpunkt-Crop). */
|
||||
ar?: string;
|
||||
/** fill | contain | cover */
|
||||
fit?: "fill" | "contain" | "cover";
|
||||
/** Ausgabeformat */
|
||||
format?: "jpeg" | "png" | "webp" | "avif";
|
||||
/** JPEG-Qualität 1–100 */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORMAT = "jpeg";
|
||||
const SUBDIR = "images/transformed";
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
const base = (
|
||||
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Stabile Dateiendung aus format. */
|
||||
function formatToExt(format: string): string {
|
||||
switch (format.toLowerCase()) {
|
||||
case "png":
|
||||
return "png";
|
||||
case "webp":
|
||||
return "webp";
|
||||
case "avif":
|
||||
return "avif";
|
||||
default:
|
||||
return "jpg";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen stabilen Hash aus URL und allen Transform-Parametern.
|
||||
* Gleiche URL + gleiche Params → gleicher Hash → keine erneute Transformation.
|
||||
*/
|
||||
export function hashForTransform(
|
||||
url: string,
|
||||
params: RustyImageTransformParams = {},
|
||||
): string {
|
||||
const normalized = {
|
||||
url,
|
||||
w: params.width,
|
||||
h: params.height,
|
||||
ar: params.ar,
|
||||
fit: params.fit ?? "contain",
|
||||
format: params.format ?? DEFAULT_FORMAT,
|
||||
quality: params.quality ?? 85,
|
||||
};
|
||||
const payload = JSON.stringify(normalized);
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass das transformierte Bild in public existiert.
|
||||
* Wenn nicht: GET an RustyCMS /api/transform, Ergebnis in public/images/transformed/<hash>.<ext> schreiben.
|
||||
* Gibt den öffentlichen Pfad zurück (z. B. /images/transformed/abc123def456.jpg).
|
||||
*/
|
||||
export async function ensureTransformedImage(
|
||||
url: string,
|
||||
params: RustyImageTransformParams = {},
|
||||
): Promise<string> {
|
||||
const format = params.format ?? DEFAULT_FORMAT;
|
||||
const ext = formatToExt(format);
|
||||
const hash = hashForTransform(url, params);
|
||||
const { width, height } = params;
|
||||
const dimSuffix =
|
||||
width != null && height != null
|
||||
? `-${width}x${height}`
|
||||
: width != null
|
||||
? `-${width}w`
|
||||
: height != null
|
||||
? `-${height}h`
|
||||
: "";
|
||||
|
||||
const projectRoot =
|
||||
typeof process !== "undefined" && process.cwd ? process.cwd() : undefined;
|
||||
if (!projectRoot) {
|
||||
throw new Error("rusty-image: project root (process.cwd) not available");
|
||||
}
|
||||
|
||||
const publicDir = path.join(projectRoot, "public");
|
||||
const dir = path.join(publicDir, SUBDIR);
|
||||
const filename = `${hash}${dimSuffix}.${ext}`;
|
||||
const filePath = path.join(dir, filename);
|
||||
const publicPath = `/${SUBDIR}/${filename}`;
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return publicPath;
|
||||
} catch {
|
||||
/* Datei existiert nicht, laden und schreiben */
|
||||
}
|
||||
|
||||
const baseUrl = getCmsBaseUrl();
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("url", url);
|
||||
if (params.width != null) searchParams.set("w", String(params.width));
|
||||
if (params.height != null) searchParams.set("h", String(params.height));
|
||||
if (params.ar != null) searchParams.set("ar", params.ar);
|
||||
if (params.fit != null) searchParams.set("fit", params.fit);
|
||||
if (params.format != null) searchParams.set("format", params.format);
|
||||
if (params.quality != null)
|
||||
searchParams.set("quality", String(params.quality));
|
||||
|
||||
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
|
||||
const res = await fetch(transformUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`rusty-image: transform failed ${res.status} for ${url}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/** Ein Eintrag aus einem Bild-Array: URL-String oder aufgelöstes img-Objekt (src). */
|
||||
type ImageArrayItem = string | { src?: string };
|
||||
|
||||
function toImageUrl(item: ImageArrayItem): string | null {
|
||||
if (typeof item === "string")
|
||||
return item.startsWith("//") ? `https:${item}` : item;
|
||||
if (item && typeof item === "object" && typeof item.src === "string")
|
||||
return item.src.startsWith("//") ? `https:${item.src}` : item.src;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst ein Array von Bild-URLs/Referenzen über die Transform-API auf.
|
||||
* Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
|
||||
* Gibt ein Array von öffentlichen Pfaden zurück (z. B. für Fullwidth-Banner).
|
||||
*/
|
||||
export async function resolveImageUrls(
|
||||
items: ImageArrayItem[],
|
||||
params: RustyImageTransformParams = {},
|
||||
): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const item of items) {
|
||||
const u = toImageUrl(item);
|
||||
if (!u || !u.startsWith("http")) continue;
|
||||
try {
|
||||
result.push(await ensureTransformedImage(u, params));
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", u, e);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Holt die Bild-URL aus einem Image-Block (image oder img: string, src oder file.url). */
|
||||
function getImageBlockUrl(block: {
|
||||
image?: string | { src?: string; file?: { url?: string }; title?: string };
|
||||
img?: string | { src?: string; file?: { url?: string }; title?: string };
|
||||
}): string | null {
|
||||
const source = block.image ?? block.img;
|
||||
if (typeof source === "string" && source.startsWith("http")) return source;
|
||||
if (source && typeof source === "object") {
|
||||
const u =
|
||||
"src" in source && typeof source.src === "string" ? source.src : source.file?.url;
|
||||
if (typeof u === "string") return u.startsWith("//") ? `https:${u}` : u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
|
||||
export interface RowContentLayoutLike {
|
||||
row1Content?: unknown[];
|
||||
row2Content?: unknown[];
|
||||
row3Content?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geht alle Content-Rows durch und setzt bei Image-Blöcken resolvedImageSrc
|
||||
* (transformiert über RustyCMS und speichert in public).
|
||||
*/
|
||||
export async function resolveContentImages(
|
||||
layout: RowContentLayoutLike,
|
||||
transformParams: RustyImageTransformParams = {},
|
||||
): Promise<void> {
|
||||
const rows = [
|
||||
layout.row1Content,
|
||||
layout.row2Content,
|
||||
layout.row3Content,
|
||||
].filter((r): r is unknown[] => Array.isArray(r));
|
||||
|
||||
for (const content of rows) {
|
||||
for (const item of content) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const block = item as {
|
||||
_type?: string;
|
||||
image?: unknown;
|
||||
img?: unknown;
|
||||
resolvedImageSrc?: string;
|
||||
};
|
||||
if (block._type !== "image") continue;
|
||||
const url = getImageBlockUrl(
|
||||
block as {
|
||||
image?: string | { file?: { url?: string }; title?: string };
|
||||
img?: string | { file?: { url?: string }; title?: string };
|
||||
},
|
||||
);
|
||||
if (!url) continue;
|
||||
try {
|
||||
block.resolvedImageSrc = await ensureTransformedImage(
|
||||
url,
|
||||
transformParams,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-24
@@ -1,46 +1,56 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getPageSlugs, getPageBySlug } from '../lib/cms';
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ContentRows from "../components/ContentRows.svelte";
|
||||
import { getPageSlugs, getPageBySlug } from "../lib/cms";
|
||||
import type { PageEntry } from "../lib/cms";
|
||||
import { resolveContentImages } from "../lib/rusty-image";
|
||||
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks } from "../lib/blog-utils";
|
||||
import { PAGE_RESOLVE } from "../lib/constants";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const slugs = await getPageSlugs();
|
||||
return slugs.map((slug) => ({ params: { slug } }));
|
||||
const pageSlugs = await getPageSlugs();
|
||||
const seen = new Set<string>();
|
||||
const paths: { params: { slug: string } }[] = [];
|
||||
for (const s of pageSlugs ?? []) {
|
||||
const slug = (s ?? "").trim();
|
||||
if (!slug || seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
paths.push({ params: { slug } });
|
||||
}
|
||||
return paths;
|
||||
} catch (e) {
|
||||
console.error('getStaticPaths (pages):', e);
|
||||
console.error("getStaticPaths ([slug]):", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
const page = await getPageBySlug(slug);
|
||||
const page = await getPageBySlug(slug, {
|
||||
locale: "de",
|
||||
resolve: PAGE_RESOLVE,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect('/404');
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
await resolveContentImages(page);
|
||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={page.seoTitle ?? page.headline ?? page.linkName ?? slug}
|
||||
description={page.seoDescription ?? page.subheadline}
|
||||
headline={page.headline ?? page.linkName ?? page.slug ?? page._slug}
|
||||
subheadline={page.subheadline}
|
||||
topFullwidthBanner={page.topFullwidthBanner}
|
||||
showBannerInLayout={!!page.topFullwidthBanner}
|
||||
>
|
||||
<main class="mx-auto max-w-3xl px-4 py-12">
|
||||
<article>
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-zinc-900">
|
||||
{page.headline ?? page.linkName ?? page._slug}
|
||||
</h1>
|
||||
{page.subheadline && (
|
||||
<p class="mt-2 text-lg text-zinc-600">
|
||||
{page.subheadline}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<div class="prose prose-zinc max-w-none">
|
||||
<!-- Weitere Felder (z.B. row1Content, topFullwidthBanner) können hier gerendert werden -->
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<article>
|
||||
<ContentRows client:load layout={page} class="mt-8" />
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
return Astro.redirect("/posts");
|
||||
---
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const { getPosts } = await import("../../../lib/cms");
|
||||
const { sortPostsByDate, getTotalPages, getPostsPerPage } = await import("../../../lib/blog-utils");
|
||||
const posts = await getPosts();
|
||||
const sorted = sortPostsByDate(posts);
|
||||
const perPage = getPostsPerPage();
|
||||
const totalPages = getTotalPages(sorted.length, perPage);
|
||||
return Array.from({ length: totalPages - 1 }, (_, i) => ({
|
||||
params: { page: String(i + 2) },
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { page } = Astro.params as { page: string };
|
||||
return Astro.redirect(`/posts/page/${encodeURIComponent(page)}`);
|
||||
---
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const { getPosts, getTags } = await import("../../../lib/cms");
|
||||
const [posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
const fromTags = new Set((tags ?? []).map((t: { _slug?: string }) => t._slug ?? "").filter(Boolean));
|
||||
const fromPosts = new Set<string>();
|
||||
for (const p of posts ?? []) {
|
||||
for (const t of (p as { postTag?: unknown[] }).postTag ?? []) {
|
||||
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
|
||||
if (slug) fromPosts.add(slug);
|
||||
}
|
||||
}
|
||||
const allSlugs = [...new Set([...fromTags, ...fromPosts])];
|
||||
return allSlugs.map((tag) => ({ params: { tag } }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { tag } = Astro.params as { tag: string };
|
||||
return Astro.redirect(`/posts/tag/${encodeURIComponent(tag)}`);
|
||||
---
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const { getPosts, getTags } = await import("../../../../../lib/cms");
|
||||
const { filterPostsByTag, getTotalPages, getPostsPerPage } = await import("../../../../../lib/blog-utils");
|
||||
const [posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
const fromTags = new Set((tags ?? []).map((t: { _slug?: string }) => t._slug ?? "").filter(Boolean));
|
||||
const fromPosts = new Set<string>();
|
||||
for (const p of posts ?? []) {
|
||||
for (const t of (p as { postTag?: unknown[] }).postTag ?? []) {
|
||||
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
|
||||
if (slug) fromPosts.add(slug);
|
||||
}
|
||||
}
|
||||
const allSlugs = [...new Set([...fromTags, ...fromPosts])];
|
||||
const perPage = getPostsPerPage();
|
||||
const paths: { params: { tag: string; page: string } }[] = [];
|
||||
for (const tag of allSlugs) {
|
||||
const filtered = filterPostsByTag(posts ?? [], tag);
|
||||
const total = getTotalPages(filtered.length, perPage);
|
||||
for (let p = 2; p <= total; p++) paths.push({ params: { tag, page: String(p) } });
|
||||
}
|
||||
return paths;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { tag, page } = Astro.params as { tag: string; page: string };
|
||||
return Astro.redirect(`/posts/tag/${encodeURIComponent(tag)}/page/${encodeURIComponent(page)}`);
|
||||
---
|
||||
+94
-45
@@ -1,57 +1,106 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getPages, fetchOpenApi } from '../lib/cms';
|
||||
import type { PageEntry } from '../lib/cms';
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ContentRows from "../components/ContentRows.svelte";
|
||||
import {
|
||||
getPageBySlug,
|
||||
getPages,
|
||||
fetchOpenApi,
|
||||
getHomePageSlugFromConfig,
|
||||
} from "../lib/cms";
|
||||
import type { PageEntry } from "../lib/cms";
|
||||
import { resolveContentImages } from "../lib/rusty-image";
|
||||
import {
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
} from "../lib/blog-utils";
|
||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE } from "../lib/constants";
|
||||
|
||||
let openApiTitle = 'RustyCMS API';
|
||||
let pages: PageEntry[] = [];
|
||||
let page: PageEntry | null = null;
|
||||
let cmsError: string | null = null;
|
||||
let openApiTitle = "RustyCMS API";
|
||||
let pages: PageEntry[] = [];
|
||||
|
||||
try {
|
||||
const spec = await fetchOpenApi();
|
||||
const homeSlug =
|
||||
(await getHomePageSlugFromConfig({ locale: "de" })) ??
|
||||
DEFAULT_HOME_PAGE_SLUG;
|
||||
const [spec, homePage, allPages] = await Promise.all([
|
||||
fetchOpenApi().catch(() => ({ info: { title: openApiTitle } })),
|
||||
getPageBySlug(homeSlug, { locale: "de", resolve: PAGE_RESOLVE }),
|
||||
getPages(),
|
||||
]);
|
||||
openApiTitle = (spec.info?.title as string) ?? openApiTitle;
|
||||
pages = await getPages();
|
||||
page = homePage;
|
||||
pages = allPages ?? [];
|
||||
if (page) {
|
||||
await resolveContentImages(page);
|
||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
}
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="RustyAstro – Pages" description="Pages aus RustyCMS">
|
||||
<main class="mx-auto max-w-3xl px-4 py-12">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-zinc-900">
|
||||
Pages
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-500">
|
||||
Schema von <strong>{openApiTitle}</strong> (OpenAPI).
|
||||
</p>
|
||||
<ul class="mt-8 space-y-3">
|
||||
{pages.map((page) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/${encodeURIComponent(page._slug ?? page.slug)}`}
|
||||
class="block rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm transition hover:border-zinc-300 hover:shadow"
|
||||
>
|
||||
<span class="font-medium text-zinc-900">
|
||||
{page.linkName ?? page.name ?? page._slug}
|
||||
</span>
|
||||
{page.headline && (
|
||||
<span class="mt-1 block text-sm text-zinc-500">
|
||||
{page.headline}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{cmsError && (
|
||||
<div class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
{
|
||||
page ? (
|
||||
<Layout
|
||||
title={page.seoTitle ?? page.headline ?? page.linkName ?? "Startseite"}
|
||||
description={page.seoDescription ?? page.subheadline}
|
||||
headline={page.headline ?? page.linkName ?? page.slug ?? page._slug}
|
||||
subheadline={page.subheadline}
|
||||
topFullwidthBanner={page.topFullwidthBanner}
|
||||
showBannerInLayout={!!page.topFullwidthBanner}
|
||||
>
|
||||
<article>
|
||||
<ContentRows client:load layout={page} class="mt-8" />
|
||||
</article>
|
||||
</Layout>
|
||||
) : (
|
||||
<Layout title="RustyAstro – Pages" description="Pages aus RustyCMS">
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Pages</h1>
|
||||
<h2>
|
||||
Schema von <strong>{openApiTitle}</strong> (OpenAPI).
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{!cmsError && pages.length === 0 && (
|
||||
<p class="mt-6 text-zinc-500">
|
||||
Keine Pages gefunden. RustyCMS unter <code class="rounded bg-zinc-200 px-1">PUBLIC_CMS_URL</code> starten und <code class="rounded bg-zinc-200 px-1">content/page/*.json5</code> anlegen.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</Layout>
|
||||
<div class="content mt-8">
|
||||
{cmsError && (
|
||||
<div class="mt-6 rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
</div>
|
||||
)}
|
||||
{!cmsError && (
|
||||
<p class="mt-6 text-zinc-600">
|
||||
Startseite „{DEFAULT_HOME_PAGE_SLUG}“ nicht gefunden. RustyCMS unter{" "}
|
||||
<code class="rounded bg-zinc-200 px-1">PUBLIC_CMS_URL</code> starten
|
||||
und{" "}
|
||||
<code class="rounded bg-zinc-200 px-1">
|
||||
content/page/{DEFAULT_HOME_PAGE_SLUG}.json5
|
||||
</code>{" "}
|
||||
anlegen – oder eine der folgenden Pages verwenden:
|
||||
</p>
|
||||
)}
|
||||
<ul class="space-y-3 mt-4">
|
||||
{pages.map((p) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/${encodeURIComponent((p.slug ?? p._slug ?? "").replace(/^\//, "").trim() || "")}`}
|
||||
class="block rounded-sm border border-zinc-200 bg-white px-4 py-3 shadow-sm transition hover:border-green-700/30 hover:shadow"
|
||||
>
|
||||
<span class="font-medium text-zinc-900">
|
||||
{p.linkName ?? p.name ?? p.slug ?? p._slug}
|
||||
</span>
|
||||
{p.headline && (
|
||||
<span class="mt-1 block text-sm text-zinc-500">
|
||||
{p.headline}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const { getPostSlugs } = await import("../../lib/cms");
|
||||
const slugs = await getPostSlugs();
|
||||
return (slugs ?? []).map((slug) => ({ params: { slug } }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
return Astro.redirect(`/post/${encodeURIComponent(slug)}`);
|
||||
---
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ContentRows from "../../components/ContentRows.svelte";
|
||||
import Tag from "../../components/Tag.svelte";
|
||||
import Tags from "../../components/Tags.svelte";
|
||||
import { getPostSlugs, getPostBySlug } from "../../lib/cms";
|
||||
import { marked } from "marked";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
import type { PostEntry } from "../../lib/cms";
|
||||
import {
|
||||
resolveContentImages,
|
||||
ensureTransformedImage,
|
||||
} from "../../lib/rusty-image";
|
||||
import {
|
||||
resolvePostTagsInPost,
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
getPostImageUrl,
|
||||
formatPostDate,
|
||||
} from "../../lib/blog-utils";
|
||||
import { POST_RESOLVE } from "../../lib/constants";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const postSlugs = await getPostSlugs();
|
||||
const paths = (postSlugs ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.filter((slug, i, arr) => arr.indexOf(slug) === i)
|
||||
.map((slug) => ({ params: { slug } }));
|
||||
return paths;
|
||||
} catch (e) {
|
||||
console.error("getStaticPaths (post/[slug]):", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
const post = await getPostBySlug(slug, {
|
||||
locale: "de",
|
||||
resolve: POST_RESOLVE,
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
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 ?? post.date);
|
||||
|
||||
const postContent = (post as { content?: string }).content;
|
||||
const contentHtml =
|
||||
typeof postContent === "string" && postContent.trim()
|
||||
? (marked.parse(postContent) as string)
|
||||
: "";
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={post.seoTitle ?? post.headline ?? post.linkName ?? slug}
|
||||
description={post.seoDescription ?? post.subheadline ?? post.excerpt}
|
||||
headline={post.headline ?? post.linkName ?? post.slug ?? post._slug}
|
||||
subheadline={post.subheadline}
|
||||
topFullwidthBanner={undefined}
|
||||
showBannerInLayout={false}
|
||||
image={postImageUrl ?? undefined}
|
||||
>
|
||||
<div class="md:flex gap-2 items-end">
|
||||
{
|
||||
postImageUrl && (
|
||||
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
||||
<img
|
||||
src={postImageUrl}
|
||||
alt=""
|
||||
class="block object-cover"
|
||||
width="150"
|
||||
height="200"
|
||||
style="aspect-ratio: 1.3333"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
|
||||
{
|
||||
postDate && (
|
||||
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
|
||||
<Tag label={postDate} variant="date" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<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={post.postTag ?? []} variant="green" tagBase="/posts/tag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article class="mt-8">
|
||||
{
|
||||
contentHtml ? (
|
||||
<div
|
||||
class="content markdown max-w-none prose prose-zinc"
|
||||
set:html={contentHtml}
|
||||
/>
|
||||
) : (
|
||||
<ContentRows client:load layout={post} />
|
||||
)
|
||||
}
|
||||
</article>
|
||||
</Layout>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import BlogOverview from '../../components/BlogOverview.svelte';
|
||||
import {
|
||||
getPosts,
|
||||
getTags,
|
||||
} from '../../lib/cms';
|
||||
import {
|
||||
sortPostsByDate,
|
||||
filterPostsByTag,
|
||||
getTotalPages,
|
||||
paginate,
|
||||
getPostsPerPage,
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../lib/blog-utils';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
let cmsError: string | null = null;
|
||||
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
const tagsMap = await getTagsMap();
|
||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const perPage = getPostsPerPage();
|
||||
const filtered = filterPostsByTag(posts, null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pagePosts = paginate(filtered, 1, perPage);
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Beiträge – Aktuelles"
|
||||
description="Übersicht aller Beiträge und Meldungen."
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Aktuelles und Meldungen</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
posts={pagePosts}
|
||||
tags={tags}
|
||||
activeTag={null}
|
||||
currentPage={1}
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import BlogOverview from '../../../components/BlogOverview.svelte';
|
||||
import { getPosts, getTags } from '../../../lib/cms';
|
||||
import {
|
||||
sortPostsByDate,
|
||||
filterPostsByTag,
|
||||
getTotalPages,
|
||||
paginate,
|
||||
getPostsPerPage,
|
||||
} from '../../../lib/blog-utils';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const perPage = getPostsPerPage();
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
try {
|
||||
posts = await getPosts();
|
||||
posts = sortPostsByDate(posts);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const totalPages = getTotalPages(posts.length, perPage);
|
||||
return Array.from({ length: totalPages - 1 }, (_, i) => ({
|
||||
params: { page: String(i + 2) },
|
||||
}));
|
||||
}
|
||||
|
||||
const { page: pageParam } = Astro.params as { page: string };
|
||||
const currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
|
||||
const perPage = getPostsPerPage();
|
||||
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
let cmsError: string | null = null;
|
||||
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const filtered = filterPostsByTag(posts, null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pageNum = Math.min(currentPage, totalPages);
|
||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – Seite ${pageNum}`}
|
||||
description="Übersicht aller Beiträge und Meldungen."
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Aktuelles und Meldungen</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
posts={pagePosts}
|
||||
tags={tags}
|
||||
activeTag={null}
|
||||
currentPage={pageNum}
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import BlogOverview from '../../../components/BlogOverview.svelte';
|
||||
import { getPosts, getTags } from '../../../lib/cms';
|
||||
import {
|
||||
sortPostsByDate,
|
||||
filterPostsByTag,
|
||||
getTotalPages,
|
||||
paginate,
|
||||
getPostsPerPage,
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../../lib/blog-utils';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
const perPage = getPostsPerPage();
|
||||
|
||||
export async function getStaticPaths() {
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const fromTags = new Set(tags.map((t) => t._slug ?? '').filter(Boolean));
|
||||
const fromPosts = new Set<string>();
|
||||
for (const p of posts) {
|
||||
for (const t of p.postTag ?? []) {
|
||||
const slug: string | undefined =
|
||||
typeof t === 'string' ? t : (t as { _slug?: string })?._slug;
|
||||
if (slug) fromPosts.add(slug);
|
||||
}
|
||||
}
|
||||
const allSlugs = [...new Set([...fromTags, ...fromPosts])];
|
||||
return allSlugs.map((tag) => ({ params: { tag } }));
|
||||
}
|
||||
|
||||
const { tag: tagSlug } = Astro.params as { tag: string };
|
||||
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
let cmsError: string | null = null;
|
||||
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
const tagsMap = await getTagsMap();
|
||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const filtered = filterPostsByTag(posts, tagSlug ?? null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pagePosts = paginate(filtered, 1, perPage);
|
||||
|
||||
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – ${tagName}`}
|
||||
description={`Beiträge zum Thema ${tagName}.`}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Beiträge: {tagName}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
posts={pagePosts}
|
||||
tags={tags}
|
||||
activeTag={tagSlug ?? null}
|
||||
currentPage={1}
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
import Layout from '../../../../../layouts/Layout.astro';
|
||||
import BlogOverview from '../../../../../components/BlogOverview.svelte';
|
||||
import { getPosts, getTags } from '../../../../../lib/cms';
|
||||
import {
|
||||
sortPostsByDate,
|
||||
filterPostsByTag,
|
||||
getTotalPages,
|
||||
paginate,
|
||||
getPostsPerPage,
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../../../../lib/blog-utils';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const perPage = getPostsPerPage();
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const fromTags = new Set(tags.map((t) => t._slug ?? '').filter(Boolean));
|
||||
const fromPosts = new Set<string>();
|
||||
for (const p of posts) {
|
||||
for (const t of p.postTag ?? []) {
|
||||
const slug: string | undefined =
|
||||
typeof t === 'string' ? t : (t as { _slug?: string })?._slug;
|
||||
if (slug) fromPosts.add(slug);
|
||||
}
|
||||
}
|
||||
const allSlugs: string[] = [...new Set([...fromTags, ...fromPosts])];
|
||||
const paths: { params: { tag: string; page: string } }[] = [];
|
||||
for (const tag of allSlugs) {
|
||||
const filtered = filterPostsByTag(posts, tag);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
for (let p = 2; p <= totalPages; p++) {
|
||||
paths.push({ params: { tag, page: String(p) } });
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const { tag: tagSlug, page: pageParam } = Astro.params as {
|
||||
tag: string;
|
||||
page: string;
|
||||
};
|
||||
const currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
|
||||
const perPage = getPostsPerPage();
|
||||
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
||||
let cmsError: string | null = null;
|
||||
|
||||
try {
|
||||
[posts, tags] = await Promise.all([getPosts(), getTags()]);
|
||||
posts = sortPostsByDate(posts);
|
||||
const tagsMap = await getTagsMap();
|
||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const filtered = filterPostsByTag(posts, tagSlug ?? null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pageNum = Math.min(currentPage, totalPages);
|
||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
|
||||
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – ${tagName} (Seite ${pageNum})`}
|
||||
description={`Beiträge zum Thema ${tagName}.`}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Beiträge: {tagName}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
posts={pagePosts}
|
||||
tags={tags}
|
||||
activeTag={tagSlug ?? null}
|
||||
currentPage={pageNum}
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Dynamische robots.txt mit Sitemap-URL aus astro.config (site).
|
||||
* Ausgabe: /robots.txt
|
||||
*/
|
||||
export const prerender = true;
|
||||
|
||||
function getRobotsTxt(): string {
|
||||
const base = (import.meta.env.SITE || "").replace(/\/$/, "");
|
||||
const sitemap = base ? `${base}/sitemap-index.xml` : "/sitemap-index.xml";
|
||||
return [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"",
|
||||
`Sitemap: ${sitemap}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function GET(): Response {
|
||||
return new Response(getRobotsTxt(), {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
+270
-1
@@ -1 +1,270 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/lora";
|
||||
@import "@fontsource-variable/rubik";
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-body:
|
||||
"Rubik Variable", Arial, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--font-secondary: "Lora Variable", Georgia, serif;
|
||||
|
||||
--color-font: #020617; /* slate-950 */
|
||||
--color-font-highlight: #f87171; /* red-400 */
|
||||
--color-navigation: #fff;
|
||||
--background-color: #020617;
|
||||
--color-text: #000;
|
||||
--color-headings: #000;
|
||||
--color-link: #15803d; /* green-700 */
|
||||
--color-btn-bg: #15803d;
|
||||
--color-btn-hover-bg: #166534; /* green-800 */
|
||||
--color-btn-txt: #fff;
|
||||
--color-container-breakout: #000000;
|
||||
|
||||
font-family: var(--font-body);
|
||||
font-size: 18px;
|
||||
line-height: 1.555;
|
||||
color: var(--color-font);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
main {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--color-headings);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply font-bold uppercase text-2xl;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
/* Page-Titel wie www.windwiderstand.de */
|
||||
.pageTitle h1 {
|
||||
@apply text-2xl font-extrabold mb-2;
|
||||
}
|
||||
|
||||
.pageTitle h2 {
|
||||
@apply text-base font-medium normal-case;
|
||||
}
|
||||
|
||||
.pageTitle h1,
|
||||
.pageTitle h2 {
|
||||
text-wrap: balance;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.pageTitle strong {
|
||||
@apply font-extrabold;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pageTitle h1 {
|
||||
@apply text-4xl;
|
||||
}
|
||||
.pageTitle h2 {
|
||||
@apply text-2xl pl-1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.pageTitle h1 {
|
||||
@apply text-6xl;
|
||||
}
|
||||
.pageTitle h2 {
|
||||
@apply text-3xl pl-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typo im Content */
|
||||
main a:not(.no-underline) {
|
||||
@apply underline text-green-700 hover:text-green-800;
|
||||
}
|
||||
|
||||
main strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
main ul {
|
||||
margin-left: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
main ul li {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
main ol {
|
||||
margin-left: 1.1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
main ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply no-underline transition font-light p-2 px-4 rounded shadow-md;
|
||||
background: var(--color-btn-bg);
|
||||
color: var(--color-btn-txt);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-btn-hover-bg);
|
||||
}
|
||||
|
||||
/* Content-Blöcke (wie www.windwiderstand.de) */
|
||||
.content p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.content h1,
|
||||
.content h2,
|
||||
.content h3,
|
||||
.content h4 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Markdown-Content: Abstände, Listen, Bilder, Tabellen */
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown hr {
|
||||
@apply mt-4 mb-3;
|
||||
}
|
||||
|
||||
.markdown p,
|
||||
.markdown li {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
@apply max-w-[400px] w-full;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
@apply w-full border-collapse text-left text-sm;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown thead {
|
||||
@apply bg-slate-100;
|
||||
}
|
||||
|
||||
.markdown th {
|
||||
@apply px-3 py-2 font-semibold border border-slate-300;
|
||||
}
|
||||
|
||||
.markdown td {
|
||||
@apply px-3 py-2 border border-slate-300;
|
||||
}
|
||||
|
||||
.markdown tbody tr:nth-child(even) {
|
||||
@apply bg-slate-50;
|
||||
}
|
||||
|
||||
.markdown tbody tr:hover {
|
||||
@apply bg-slate-100/80;
|
||||
}
|
||||
|
||||
.markdown table a {
|
||||
@apply text-green-700 underline underline-offset-2 break-all;
|
||||
}
|
||||
|
||||
.markdown table a:hover {
|
||||
@apply text-green-800;
|
||||
}
|
||||
|
||||
/* Container wie Referenz (max-width Stufen) */
|
||||
.container-custom {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container-custom {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container-custom {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container-custom {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container-custom {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quote-Block: Rubik Variable */
|
||||
[data-block-type="quote"] blockquote,
|
||||
[data-block-type="quote"] blockquote p,
|
||||
[data-block-type="quote"] blockquote cite {
|
||||
font-family: var(--font-secondary);
|
||||
}
|
||||
|
||||
/* Content-Rows: Abstand zwischen Zeilen (wie Referenz) */
|
||||
.content-row + .content-row {
|
||||
@apply mt-6;
|
||||
}
|
||||
|
||||
/* Footer-Links wie Referenz */
|
||||
footer a,
|
||||
.content-footer a {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
footer a:hover,
|
||||
.content-footer a:hover {
|
||||
@apply text-green-800;
|
||||
}
|
||||
|
||||
.container-breakout {
|
||||
margin-left: calc(50% - 50vw + 0.01rem);
|
||||
margin-right: calc(50% - 50vw + 0.01rem);
|
||||
background-color: var(--color-container-breakout);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user