Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

Full parity with Astro site: content rows, post/tag routes, pagination,
event badges + OSM map, comments, Live-Search via /api/search-index,
CMS image proxy, RSS, sitemap.

Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow
to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
build/
.svelte-kit/
.cache/
.env
.env.*
*.log
*.local
.DS_Store
+26
View File
@@ -0,0 +1,26 @@
PUBLIC_CMS_URL=http://localhost:3000
PUBLIC_SITE_URL=https://www.windwiderstand.de
PUBLIC_SITE_NAME=Windwiderstand
PUBLIC_UMAMI_SCRIPT_URL=
PUBLIC_UMAMI_WEBSITE_ID=
# analytics.pm86.de Site-ID (Script-URL ist hartcodiert auf analytics.pm86.de)
PUBLIC_ANALYTICS_PM86_SITE_ID=
# Formbricks Feedback-Widget
PUBLIC_FORMBRICKS_APP_URL=
PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
# Persistenter Disk-Cache für /cms-images (Default: .cache/images)
# IMAGE_CACHE_DIR=.cache/images
# Optional: In-Memory-Einträge vor Disk-Lesen (Default 200)
# IMAGE_CACHE_MAX_MEMORY=200
# Optional: Legacy-Proxy /api/transform (In-Memory + optional Datei)
# TRANSFORM_CACHE_MAX_ENTRIES=100
# TRANSFORM_CACHE_DIR=.cache/transform
# Shared Secret für RustyCMS Webhook → POST /api/revalidate
# Gleicher Wert muss in der Webhook-URL als ?token=... hängen.
# Generieren: openssl rand -hex 32
REVALIDATE_TOKEN=
+103
View File
@@ -0,0 +1,103 @@
# Deploy windwiderstand SvelteKit auf Contabo bei Push auf main.
#
# Setup auf dem Server (einmalig):
# mkdir -p /opt/windwiderstand
# DNS: windwiderstand.de + www.windwiderstand.de A → 167.86.74.105
#
# Gitea Secrets (Repo → Settings → Secrets):
# SSH_DEPLOY_KEY ~/.ssh/contabo_rsa (privat)
# REGISTRY_USER Gitea-Username
# REGISTRY_TOKEN Gitea Access Token (Scope: package)
# PUBLIC_CMS_URL https://cms.pm86.de
# PUBLIC_SITE_URL https://www.windwiderstand.de
# PUBLIC_SITE_NAME Windwiderstand
# ORIGIN https://www.windwiderstand.de
# REVALIDATE_TOKEN openssl rand -hex 32
# PUBLIC_UMAMI_WEBSITE_ID (optional, sonst leer)
# PUBLIC_ANALYTICS_PM86_SITE_ID (optional, sonst leer)
name: Deploy
on:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install deps
run: npm ci
- name: Typecheck
run: npx svelte-check --threshold error
deploy:
needs: verify
runs-on: ubuntu-latest
env:
DEPLOY_HOST: root@167.86.74.105
DEPLOY_DIR: /opt/windwiderstand
IMAGE: git.pm86.de/admin/windwiderstand:latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.pm86.de -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker build -t $IMAGE .
docker push $IMAGE
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
cat > ~/.ssh/config <<EOF
Host vserver
HostName 167.86.74.105
User root
IdentityFile ~/.ssh/deploy_key
StrictHostKeyChecking no
UserKnownHostsFile=/dev/null
EOF
- name: Sync compose.yml
run: |
ssh vserver "mkdir -p $DEPLOY_DIR"
scp ./docker-compose.prod.yml vserver:$DEPLOY_DIR/docker-compose.yml
- name: Write .env from secrets
run: |
ssh vserver "cat > $DEPLOY_DIR/.env" <<ENVEOF
PORT=3001
NODE_ENV=production
PUBLIC_CMS_URL=${{ secrets.PUBLIC_CMS_URL }}
PUBLIC_SITE_URL=${{ secrets.PUBLIC_SITE_URL }}
PUBLIC_SITE_NAME=${{ secrets.PUBLIC_SITE_NAME }}
ORIGIN=${{ secrets.ORIGIN }}
REVALIDATE_TOKEN=${{ secrets.REVALIDATE_TOKEN }}
IMAGE_CACHE_DIR=/data/image-cache
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
PUBLIC_ANALYTICS_PM86_SITE_ID=${{ secrets.PUBLIC_ANALYTICS_PM86_SITE_ID }}
ENVEOF
- name: Pull and restart container
run: |
ssh vserver "echo '${{ secrets.REGISTRY_TOKEN }}' | docker login git.pm86.de -u ${{ secrets.REGISTRY_USER }} --password-stdin"
ssh vserver "docker pull $IMAGE"
ssh vserver "cd $DEPLOY_DIR && docker compose up -d --force-recreate && docker image prune -f"
- name: Caddy site block + reload
run: |
ssh vserver 'grep -q "^windwiderstand.de" /opt/caddy/extra-config/Caddyfile || printf "\nwindwiderstand.de, www.windwiderstand.de {\n reverse_proxy windwiderstand:3001\n}\n" >> /opt/caddy/extra-config/Caddyfile'
ssh vserver "docker exec caddy caddy reload --config /extra-config/Caddyfile"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
+21
View File
@@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Build / SvelteKit
build/
.svelte-kit/
dist/
# Environment
.env
.env.*
!.env.example
# Local / IDE
.cache/
.history/
.DS_Store
# Logs
*.log
npm-debug.log*
+38
View File
@@ -0,0 +1,38 @@
# ─────────────────────────────────────────────
# Stage 1: Build
# ─────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
# Erst nur package*.json besseres Layer-Caching
COPY package*.json ./
# postinstall versucht CMS-Types zu generieren (|| true → unkritisch ohne CMS)
RUN npm ci
COPY . .
RUN npm run build
# ─────────────────────────────────────────────
# Stage 2: Schlankes Production-Image
# ─────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Nur Production-Deps, kein postinstall nötig
COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts
# Build-Artefakt aus Stage 1
COPY --from=builder /app/build ./build
# Non-root user
RUN addgroup -g 1001 -S nodejs \
&& adduser -S svelte -u 1001 -G nodejs \
&& chown -R svelte:nodejs /app
USER svelte
EXPOSE 3001
ENV PORT=3001
CMD ["node", "build"]
+20
View File
@@ -0,0 +1,20 @@
services:
windwiderstand:
image: git.pm86.de/admin/windwiderstand:latest
container_name: windwiderstand
restart: unless-stopped
expose:
- "3001"
env_file: /opt/windwiderstand/.env
volumes:
- image-cache:/data/image-cache
networks:
- web
volumes:
image-cache:
networks:
web:
external: true
name: stacks_web
+17
View File
@@ -0,0 +1,17 @@
services:
windwiderstand:
build: .
ports:
- "3001:3001"
environment:
- PUBLIC_CMS_URL=${PUBLIC_CMS_URL:-http://rustycms:3000}
- PUBLIC_SITE_URL=${PUBLIC_SITE_URL:-https://www.windwiderstand.de}
- PUBLIC_SITE_NAME=${PUBLIC_SITE_NAME:-Windwiderstand}
- ORIGIN=${ORIGIN:-https://www.windwiderstand.de}
- IMAGE_CACHE_DIR=${IMAGE_CACHE_DIR:-/app/.cache/images}
volumes:
- image-cache:/app/.cache/images
restart: unless-stopped
volumes:
image-cache:
+2780
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "windwiderstand",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"generate:api-types": "node scripts/generate-api-types.mjs",
"postinstall": "node scripts/generate-api-types.mjs || true"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22.10.0",
"openapi-typescript": "^7.4.0",
"svelte": "^5.28.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.3.5"
},
"dependencies": {
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1",
"marked": "^17.0.2"
},
"type": "module"
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Generiert TypeScript-Typen aus der RustyCMS OpenAPI-Spec.
* Verwendet PUBLIC_CMS_URL aus .env (Default: http://localhost:3000).
* RustyCMS muss laufen: cd ../rustycms && cargo run
*/
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const envPath = join(root, ".env");
if (existsSync(envPath)) {
const lines = readFileSync(envPath, "utf8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
const idx = trimmed.indexOf("=");
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
if (key && !process.env[key]) process.env[key] = value;
}
}
}
const base = (process.env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
const specUrl = `${base}/api-docs/openapi.json`;
const outFile = join(root, "src/lib/cms-api.generated.ts");
console.log("Fetching OpenAPI spec from", specUrl);
console.log("Writing types to", outFile);
try {
execSync(`npx openapi-typescript "${specUrl}" -o "${outFile}"`, {
stdio: "inherit",
cwd: root,
});
console.log("Done. Types are in src/lib/cms-api.generated.ts");
} catch (err) {
console.error("\nFehler: OpenAPI-Spec konnte nicht geladen werden.");
console.error("Ist RustyCMS gestartet? z.B.: cd ../rustycms && cargo run");
process.exit(1);
}
+491
View File
@@ -0,0 +1,491 @@
/* Windwiderstand Design System Fonts */
@import "@fontsource/inter/latin-300.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
@import "@fontsource/inter/latin-700.css";
@import "@fontsource/inter/latin-800.css";
@import "@fontsource/inter/latin-900.css";
@import "@fontsource-variable/lora";
@import "tailwindcss";
/* ==========================================================================
Design Tokens Windwiderstand (01-colors, 02-typography, 06-tokens.json)
========================================================================== */
@theme {
/* Palette: Wald (Primary) */
--color-wald-50: #f0f5f1;
--color-wald-100: #d9e8dc;
--color-wald-200: #b3d1b9;
--color-wald-300: #7fb58a;
--color-wald-400: #4a9960;
--color-wald-500: #2d7a45;
--color-wald-600: #236335;
--color-wald-700: #1a4d28;
--color-wald-800: #12361c;
--color-wald-900: #0a2011;
/* Palette: Erde (Secondary) */
--color-erde-50: #faf6f1;
--color-erde-100: #f0e6d6;
--color-erde-200: #e0ccad;
--color-erde-300: #c9a87a;
--color-erde-400: #b08a52;
--color-erde-500: #8b6d3f;
--color-erde-600: #6e5530;
--color-erde-700: #524023;
--color-erde-800: #372b18;
--color-erde-900: #1c160c;
/* Palette: Himmel (Accent / Links) */
--color-himmel-50: #f2f5f7;
--color-himmel-100: #dce4ea;
--color-himmel-200: #b8c9d4;
--color-himmel-300: #8aaabb;
--color-himmel-400: #5e8ba2;
--color-himmel-500: #436e85;
--color-himmel-600: #34576a;
--color-himmel-700: #264150;
--color-himmel-800: #1a2c36;
--color-himmel-900: #0e181e;
/* Palette: Stein (Neutrals) */
--color-stein-0: #ffffff;
--color-stein-50: #f7f8f7;
--color-stein-100: #eceeed;
--color-stein-200: #d5d8d6;
--color-stein-300: #b0b5b2;
--color-stein-400: #868c89;
--color-stein-500: #636966;
--color-stein-600: #4a4f4c;
--color-stein-700: #333735;
--color-stein-800: #1f2221;
--color-stein-900: #0f1110;
/* Semantic: Success, Warning, Error, Info */
--color-success: var(--color-wald-500);
--color-success-subtle: var(--color-wald-50);
--color-warning: #a6780a;
--color-warning-subtle: #fdf8ec;
--color-error: #b53629;
--color-error-subtle: #fdf2f1;
--color-info: var(--color-himmel-500);
--color-info-subtle: var(--color-himmel-50);
/* Button (Primary = Wald) für @apply bg-btn-bg, text-btn-txt, bg-btn-hover-bg */
--color-btn-bg: var(--color-wald-500);
--color-btn-hover-bg: var(--color-wald-600);
--color-btn-txt: #fff;
/* Link (Himmel) für @apply text-link */
--color-link: var(--color-himmel-500);
}
:root {
/* Typography Inter (primary), Lora (quotes) */
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-secondary: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
/* Semantic (nur Light Theme) */
--bg-primary: #ffffff;
--bg-secondary: #f7f8f7;
--bg-tertiary: #eceeed;
--bg-elevated: #ffffff;
--text-primary: #1f2221;
--text-secondary: #636966;
--text-tertiary: #868c89;
--border-default: #d5d8d6;
--border-strong: #b0b5b2;
--accent: #2d7a45;
--accent-hover: #236335;
--link: #436e85;
/* Legacy / App-Aliase (weiterverwendet) */
--color-font: var(--text-primary);
--color-headings: var(--text-primary);
--color-font-highlight: var(--color-error);
--color-navigation: var(--color-stein-0);
--background-color: var(--bg-primary);
--color-text: var(--text-primary);
--color-link: var(--link);
--color-btn-bg: var(--accent);
--color-btn-hover-bg: var(--accent-hover);
--color-btn-txt: #fff;
/* Breakout-Bereich: Verlauf Wald-50 → Wald-100 */
--color-container-breakout: linear-gradient(
to bottom,
white,
var(--color-wald-50)
);
font-family: var(--font-body);
font-size: 1.125rem; /* 18px Body Large laut Design System */
line-height: 1.556;
color: var(--color-font);
}
html {
background: var(--background-color);
}
main {
background: var(--bg-primary);
}
/* ==========================================================================
Typography Type Scale (02-typography.md)
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Lora nur Zitate.
========================================================================== */
h1,
h2,
h3,
h4 {
color: var(--color-headings);
}
/* Mobile first, dann Tablet (768px), Desktop (1024px) */
h1 {
font-size: 1.75rem;
line-height: 2.125rem;
font-weight: 700;
letter-spacing: -0.015em;
}
h2 {
font-size: 1.375rem;
line-height: 1.75rem;
font-weight: 700;
letter-spacing: -0.01em;
}
h3 {
font-size: 1.25rem;
line-height: 1.625rem;
font-weight: 600;
letter-spacing: -0.005em;
}
h4 {
font-size: 1.125rem;
line-height: 1.5rem;
font-weight: 600;
}
@media (min-width: 768px) {
h1 {
font-size: 2.125rem;
line-height: 2.625rem;
}
h2 {
font-size: 1.5rem;
line-height: 2rem;
}
h3 {
font-size: 1.375rem;
line-height: 1.875rem;
}
h4 {
font-size: 1.125rem;
line-height: 1.625rem;
}
}
@media (min-width: 1024px) {
h1 {
font-size: 2.5rem;
line-height: 3rem;
}
h2 {
font-size: 1.75rem;
line-height: 2.25rem;
}
h3 {
font-size: 1.5rem;
line-height: 2rem;
}
}
/* Page-Titel (wie www.windwiderstand.de) */
.pageTitle h1 {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.pageTitle h2 {
font-size: 1rem;
line-height: 1.5rem;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
}
.pageTitle h1,
.pageTitle h2 {
text-wrap: balance;
word-break: break-word;
overflow-wrap: break-word;
}
.pageTitle strong {
font-weight: 700;
}
@media (min-width: 768px) {
.pageTitle h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
.pageTitle h2 {
font-size: 1.5rem;
line-height: 2rem;
padding-left: 0.25rem;
}
}
@media (min-width: 1024px) {
.pageTitle h1 {
font-size: 3.75rem;
line-height: 1.14;
font-weight: 700;
letter-spacing: -0.02em;
}
.pageTitle h2 {
font-size: 1.875rem;
line-height: 2.25rem;
padding-left: 0.25rem;
}
}
/* Content-Links: Himmel (Design System Links) */
main a:not(.no-underline) {
color: var(--color-link);
text-decoration: underline;
}
main a:not(.no-underline):hover {
color: var(--color-himmel-600);
}
/* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */
main a,
.markdown a {
overflow-wrap: break-word;
word-break: break-all;
}
main strong {
font-weight: 600;
}
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 Wald (Primary) */
.btn-primary {
@apply inline-block no-underline transition-colors duration-200 font-light px-4 py-2 rounded-md shadow-sm bg-btn-bg text-btn-txt;
}
.btn-primary:hover {
@apply bg-btn-hover-bg;
}
/* Content-Blöcke */
.content p {
@apply mb-4;
}
.content h1,
.content h2,
.content h3,
.content h4 {
@apply mb-2;
}
/* Markdown: Abstände, Listen, Bilder, Tabellen (Stein-Palette) */
.markdown {
@apply min-w-0 overflow-x-auto;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown hr {
@apply mt-4 mb-3;
}
.markdown li,
.markdown li p {
@apply mt-0 mb-0;
}
.markdown p {
@apply mb-4;
}
.markdown ul,
.markdown ol {
@apply mt-0 mb-4;
}
.markdown li ul,
.markdown li ol {
@apply mb-0;
}
/* Markdown-Bilder: /cms-images-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */
.markdown img {
@apply max-w-full w-full my-2 border border-stein-200 rounded-md bg-stein-200 shadow-sm inline-block;
max-height: 30vh;
}
.markdown .markdown-image-link {
@apply inline-block;
}
.markdown table {
@apply w-full min-w-max border-collapse text-left text-sm mb-4;
}
.markdown thead {
@apply bg-stein-100;
}
.markdown th {
@apply px-3 py-2 font-medium border border-stein-200;
}
.markdown td {
@apply px-3 py-2 border border-stein-200;
}
.markdown tbody tr:nth-child(even) {
@apply bg-stein-50;
}
.markdown tbody tr:hover {
@apply bg-stein-100;
}
.markdown table a {
@apply text-link underline underline-offset-2 break-all;
}
.markdown table a:hover {
@apply text-himmel-600;
}
/* Code: Inline und Block (pre/code) lesbare Größe, Stein-Palette */
main code,
.content code,
.markdown code {
@apply font-mono bg-stein-100 text-stein-800 border border-stein-200;
@apply text-sm px-1.5 py-0.5;
}
main pre,
.content pre,
.markdown pre {
@apply mt-3 mb-4 overflow-x-auto rounded-md border border-stein-200 bg-stein-100 px-4 py-3 text-stein-800 leading-normal;
}
main pre code,
.content pre code,
.markdown pre code {
@apply border-0 bg-transparent p-0 text-inherit;
}
/* Container */
.container-custom {
@apply w-full mx-auto px-4 md:px-6;
}
@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;
}
}
/* Zitate: Lora (Design System nur für Zitate) */
[data-block-type="quote"] blockquote,
[data-block-type="quote"] blockquote p,
[data-block-type="quote"] blockquote cite {
font-family: var(--font-secondary);
}
.content-row + .content-row {
margin-top: 1.5rem;
}
/* Footer */
footer a,
.content-footer a {
color: var(--color-link);
}
footer a:hover,
.content-footer a:hover {
color: var(--color-himmel-600);
}
.container-breakout {
margin-left: calc(50% - 50vw + 0.01rem);
margin-right: calc(50% - 50vw + 0.01rem);
background: var(--color-container-breakout);
}
/* Kalender: Container Query links Kalender, rechts Eventblock ab ~500px Containerbreite */
.calendar-block {
container-type: inline-size;
container-name: calendar;
}
@container calendar (min-width: 600px) {
.calendar-block .calendar-grid-wrapper {
grid-template-columns: auto 1fr;
}
.calendar-block .calendar-column {
min-width: 300px;
max-width: 450px;
}
.calendar-block .calendar-events-wrapper {
position: relative;
}
.calendar-block .calendar-events-panel {
position: absolute;
inset: 0;
border-top: none;
border-left: 1px solid var(--color-stein-200);
}
}
+8
View File
@@ -0,0 +1,8 @@
declare global {
namespace App {
interface Locals {
translations: Record<string, string>;
}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
import { getTranslationBundleBySlug } from '$lib/cms';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
import type { Handle } from '@sveltejs/kit';
/**
* Page-Routen: kurze Edge-Cache + SWR. Webhook purged App-Cache,
* Edge läuft via stale-while-revalidate auf den warmen App-Cache.
*/
const PAGE_CACHE_CONTROL =
'public, max-age=0, s-maxage=30, stale-while-revalidate=300';
function isCacheablePath(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
if (pathname.startsWith('/cms-images/')) return false;
if (pathname.startsWith('/_app/')) return false;
return true;
}
export const handle: Handle = async ({ event, resolve }) => {
try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' });
if (bundle?.strings && typeof bundle.strings === 'object') {
const normalized: Record<string, string> = {};
for (const [key, val] of Object.entries(bundle.strings)) {
const v = val as unknown;
const str =
typeof v === 'string'
? v
: typeof v === 'object' && v !== null && 'value' in v
? (v as { value?: unknown }).value
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
if (typeof str === 'string') normalized[key] = str;
}
event.locals.translations = normalized;
} else {
event.locals.translations = {};
}
} catch {
event.locals.translations = {};
}
const response = await resolve(event);
if (
event.request.method === 'GET' &&
isCacheablePath(event.url.pathname) &&
!response.headers.has('cache-control')
) {
response.headers.set('cache-control', PAGE_CACHE_CONTROL);
}
return response;
};
+163
View File
@@ -0,0 +1,163 @@
/**
* Layout für Content-Blöcke (z. B. Markdown): Grid-Spalten (112) und Abstand.
* Entspricht component_layout im CMS (desktop/tablet/mobile = Breite in 12tel, spaceBottom = rem).
*/
export type BlockLayout = {
/** Breite auf Desktop (112), z. B. "8" = 8/12. */
desktop?:
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
/** Breite auf Tablet (112). */
tablet?:
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
/** Breite auf Mobile (112), 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";
}
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(" ");
}
/** Tailwind-Klasse für spaceBottom (z. B. am Breakout-Wrapper). */
export function getSpaceBottomClass(
layout: BlockLayout | undefined | null,
): string {
if (!layout || typeof layout !== "object") return "mb-4";
const sb =
typeof layout.spaceBottom === "number"
? layout.spaceBottom
: Number(layout.spaceBottom);
return SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
}
/** 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);
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
* Wird von SvelteKit-Seiten und Svelte-Komponenten genutzt.
* Kalender-Typen basieren auf der OpenAPI-Spec (cms-api.generated).
*/
import type { BlockLayout } from "./block-layout";
import type { components } from "./cms-api.generated";
/** 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"). resolvedContent = HTML mit transformierten Bildern (von resolveContentImages). */
export interface MarkdownBlockData {
_type?: "markdown";
_slug?: string;
name?: string;
content?: string;
/** Gesetzt von resolveContentImages: HTML (Bilder transformiert, in Links eingewickelt). */
resolvedContent?: 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: Proxy-URL 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 };
/** Nach resolveContentImages: Proxy-URL zum transformierten Bild (16:9). */
resolvedSrc?: 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;
}
/** OpnForm embed (_type: "opnform"). */
export interface OpnFormBlockData {
_type?: "opnform";
_slug?: string;
id?: string;
formId?: string;
baseUrl?: string;
iframeTitle?: string;
layout?: BlockLayout;
}
/** Einzelne Organisation (aufgelöst aus organisation-Collection). */
export interface OrganisationEntry {
_type?: "organisation";
_slug?: string;
name?: string;
logo?: string | { src?: string; description?: string };
/** Nach resolveContentImages: Proxy-URL zum Logo (WebP). */
resolvedLogoSrc?: string;
description?: string;
link?: string | { url?: string; linkName?: string; newTab?: boolean };
badge?: string | { label?: string; color?: string };
}
/** Organisations-Block (_type: "organisations"). */
export interface OrganisationsBlockData {
_type?: "organisations";
_slug?: string;
headline?: string;
description?: string;
/** default = Karten-Grid; compact = engere Karten, kleinere Typo, Logo links */
presentation?: "default" | "compact";
addOrganisationTitle?: string;
addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
addOrganisationLink?: string | { url?: string; linkName?: string; newTab?: boolean };
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit<
components["schemas"]["calendar"],
"items"
> & {
_type?: "calendar";
/** Optionaler Titel über dem Kalender. */
title?: string;
/** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
};
+582
View File
@@ -0,0 +1,582 @@
import type { PostEntry } from "./cms";
import {
getPosts,
getPostBySlug,
getTags,
getTextFragmentBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
} from "./cms";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types";
import { ensureTransformedImage } from "./rusty-image";
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
export type TagMeta = {
name: string;
icon?: string;
color?: string;
};
/** Slug → TagMeta aus der Tag-API. Für Auflösung von post.postTag. */
export async function getTagsMap(): Promise<Map<string, TagMeta>> {
const tags = await getTags();
const m = new Map<string, TagMeta>();
for (const t of tags) {
const slug =
(t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? "";
if (!slug) continue;
const name = (t as { name?: string }).name?.trim() ?? slug;
const icon = (t as { icon?: string }).icon?.trim();
const color = (t as { color?: string }).color?.trim();
m.set(slug, {
name,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
});
}
return m;
}
export type ResolvedPostTag = {
_slug: string;
name: string;
icon?: string;
color?: string;
};
/** Setzt post.postTag auf { _slug, name, icon?, color? }[]; Metadaten aus slugToMeta bzw. bereits aufgelösten Refs. */
export function resolvePostTagsInPost(
post: PostEntry,
slugToMeta: Map<string, TagMeta>,
): void {
const raw = post.postTag ?? [];
if (!Array.isArray(raw)) return;
(post as { postTag?: ResolvedPostTag[] }).postTag = raw.map((t) => {
if (typeof t === "string") {
const meta = slugToMeta.get(t);
return {
_slug: t,
name: meta?.name ?? t,
...(meta?.icon ? { icon: meta.icon } : {}),
...(meta?.color ? { color: meta.color } : {}),
};
}
const o = t as {
_slug?: string;
name?: string;
icon?: string;
color?: string;
};
const slug = o._slug ?? o?.name ?? "";
const meta = slug ? slugToMeta.get(slug) : undefined;
const icon = (o.icon ?? meta?.icon)?.trim();
const color = (o.color ?? meta?.color)?.trim();
return {
_slug: slug,
name: o?.name ?? meta?.name ?? slug,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
};
});
}
const POSTS_PER_PAGE = 10;
export function getPostsPerPage(): number {
return POSTS_PER_PAGE;
}
/** Filtert Posts mit hideFromListing: true aus Übersichten heraus. */
export function filterHiddenPosts(posts: PostEntry[]): PostEntry[] {
return posts.filter(
(p) => !(p as PostEntry & { hideFromListing?: boolean }).hideFromListing,
);
}
/** Sortiert Posts nach Datum (neueste zuerst). */
export function sortPostsByDate(posts: PostEntry[]): PostEntry[] {
return [...posts].sort((a, b) => {
const da = a.created ?? "";
const db = b.created ?? "";
if (!da) return 1;
if (!db) return -1;
return new Date(db).getTime() - new Date(da).getTime();
});
}
/** Filtert Posts nach Tag-Slug (post.postTag enthält den Slug). */
export function filterPostsByTag(
posts: PostEntry[],
tagSlug: string | null,
): PostEntry[] {
if (!tagSlug) return posts;
return posts.filter((p) => {
const tags = p.postTag ?? [];
return tags.some((t) => {
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
return slug === tagSlug;
});
});
}
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
export function getPostImageUrl(
postImage: PostEntry["postImage"],
): string | null {
if (!postImage || typeof postImage === "string") return null;
const obj = postImage as {
src?: string;
file?: { url?: string };
fields?: { file?: { url?: string } };
};
const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url;
return typeof url === "string"
? url.startsWith("//")
? `https:${url}`
: url
: null;
}
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
export function filterPostsByTagSlugs(
posts: PostEntry[],
tagSlugs: string[],
): PostEntry[] {
if (!tagSlugs?.length) return posts;
return posts.filter((p) => {
const tags = p.postTag ?? [];
return tags.some((t) => {
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
return tagSlugs.includes(slug ?? "");
});
});
}
export function getTotalPages(count: number, perPage: number): number {
return Math.max(1, Math.ceil(count / perPage));
}
export function paginate<T>(items: T[], page: number, perPage: number): T[] {
const start = (page - 1) * perPage;
return items.slice(start, start + perPage);
}
export type PostSearchIndexEntry = {
slug: string;
headline: string;
excerpt: string;
subheadline: string;
tags: string[];
image: string | null;
created: string | null;
isEvent: boolean;
eventDate: string | null;
};
type PostWithEventIndex = PostEntry & {
isEvent?: boolean;
eventDate?: string;
_resolvedImageUrl?: string;
};
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
return posts.map((p) => ({
slug: p.slug ?? p._slug ?? "",
headline: p.headline ?? "",
excerpt: p.excerpt ?? "",
subheadline: p.subheadline ?? "",
tags: (p.postTag ?? [])
.map((t) =>
typeof t === "string"
? t
: (t as { name?: string; _slug?: string }).name ??
(t as { _slug?: string })._slug ??
"",
)
.filter(Boolean),
image: (p as PostWithEventIndex)._resolvedImageUrl ?? null,
created: p.created ?? null,
isEvent: Boolean((p as PostWithEventIndex).isEvent),
eventDate: (p as PostWithEventIndex).eventDate ?? null,
}));
}
export type LoadPostsResult = {
posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>;
activeTag: string | null;
currentPage: number;
totalPages: number;
totalPosts: number;
upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[];
tagName: string | null;
cmsError: string | null;
};
/** Gemeinsame Ladefunktion für /posts, /posts/page/[n], /posts/tag/[t], /posts/tag/[t]/page/[n]. */
export async function loadPostsList(opts: {
tagSlug?: string | null;
page?: number;
}): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1);
const perPage = getPostsPerPage();
let posts: PostEntry[] = [];
let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null;
try {
[posts, tags] = await Promise.all([
getPosts({ resolve: ["postImage"] }),
getTags(),
]);
posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
});
(post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch {
/* image optional */
}
}
}
} catch (e) {
cmsError = e instanceof Error ? e.message : String(e);
}
const filtered = filterPostsByTag(posts, tagSlug);
const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
const tagName = tagSlug
? (tags.find((t) => ((t as { _slug?: string })._slug ?? "") === tagSlug)?.name ?? tagSlug)
: null;
const upcomingEvents = selectUpcomingEvents(posts);
const searchIndex = buildPostSearchIndex(posts);
return {
posts: pagePosts,
tags,
activeTag: tagSlug,
currentPage: pageNum,
totalPages,
totalPosts: filtered.length,
upcomingEvents,
searchIndex,
tagName,
cmsError,
};
}
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
const now = Date.now();
return (posts as PostWithEventIndex[])
.filter(
(p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= now,
)
.sort(
(a, b) =>
new Date(a.eventDate ?? 0).getTime() -
new Date(b.eventDate ?? 0).getTime(),
)
.slice(0, max) as PostEntry[];
}
const POST_BASE = "/post";
/** Post-URL: slug ist die URL; führender Slash entfernt. */
export function postHref(post: PostEntry): string {
const slug = post.slug ?? post._slug ?? "";
const norm = (slug ?? "").replace(/^\//, "").trim();
return norm ? `${POST_BASE}/${encodeURIComponent(norm)}` : POST_BASE;
}
export function formatPostDate(value: string | undefined): string {
if (!value) return "";
try {
const d = new Date(value);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
/** Prüft, ob ein Block ein Post-Overview-Block ist (mit _type "post_overview"). */
function isPostOverviewBlock(item: unknown): item is {
_type?: string;
allPosts?: boolean;
posts?: unknown[];
filterByTag?: unknown[];
numberItems?: number;
postsResolved?: unknown[];
} {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "post_overview"
);
}
/**
* Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved.
* Nutzt die globale Tag-API (getTagsMap()) für Slug → TagMeta.
* Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug]+page.server.ts).
*/
export async function resolvePostOverviewBlocks(
layout: RowContentLayout | null | undefined,
): Promise<Map<string, TagMeta>> {
const tagsMap = await getTagsMap();
if (!layout) return tagsMap;
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
let allPosts: PostEntry[] | null = null;
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isPostOverviewBlock(item)) continue;
if (item.allPosts) {
if (allPosts === null)
allPosts = await getPosts({
_sort: "created",
_order: "desc",
resolve: "all",
});
let list = filterHiddenPosts(sortPostsByDate(allPosts));
const rawFilter = item.filterByTag ?? [];
const tagSlugs = Array.isArray(rawFilter)
? rawFilter
.map((t) =>
typeof t === "string"
? t
: ((t as { _slug?: string })?._slug ?? ""),
)
.filter(Boolean)
: [];
if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs);
const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999;
item.postsResolved = list.slice(0, limit);
} else if (Array.isArray(item.posts) && item.posts.length > 0) {
const first = item.posts[0];
const isResolved =
typeof first === "object" &&
first !== null &&
"_slug" in first &&
("headline" in first || "linkName" in first);
if (isResolved) {
item.postsResolved = item.posts as PostEntry[];
} else {
const slugs = item.posts
.map((p) =>
typeof p === "string"
? p
: ((p as { _slug?: string })?._slug ?? ""),
)
.filter(Boolean);
const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999;
const resolved: PostEntry[] = [];
for (const slug of slugs.slice(0, limit)) {
const post = await getPostBySlug(slug, {
locale: "de",
resolve: ["all"],
});
if (post) resolved.push(post);
}
item.postsResolved = resolved;
}
}
if (Array.isArray(item.postsResolved)) {
const posts = item.postsResolved as PostEntry[];
if (posts.length > 0) {
for (const post of posts) resolvePostTagsInPost(post, tagsMap);
}
for (const post of item.postsResolved as PostEntry[]) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
});
(
post as PostEntry & { _resolvedImageUrl?: string }
)._resolvedImageUrl = url;
} catch {
// Bild optional
}
}
}
}
}
}
return tagsMap;
}
function isCalendarBlock(
item: unknown,
): item is { _type?: string; _slug?: string; items?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "calendar"
);
}
/**
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
*/
export async function resolveCalendarBlocks(
layout: RowContentLayout,
): Promise<void> {
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isCalendarBlock(item)) continue;
const slug = item._slug;
if (!slug) continue;
const rawItems = item.items ?? [];
const first = rawItems[0];
const alreadyResolved =
typeof first === "object" &&
first !== null &&
"terminZeit" in first &&
"title" in first;
if (alreadyResolved) continue;
const calendar = await getCalendarBySlug(slug, {
locale: "de",
resolve: "items",
});
const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = [];
for (const x of raw) {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
resolved.push(x as CalendarItemData);
} else {
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
}
(item as { items?: CalendarItemData[] }).items = resolved;
}
}
}
function isSearchableTextBlock(
item: unknown,
): item is { _type?: string; textFragments?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "searchable_text"
);
}
/** Prüft, ob ein Fragment bereits aufgelöst ist (hat title oder text). */
function isResolvedFragment(f: unknown): boolean {
if (typeof f !== "object" || f === null) return false;
const o = f as { title?: string; text?: string };
return "title" in o || "text" in o;
}
/**
* Normalisiert tags eines Fragments zu Anzeigenamen (string[]).
* Nutzt slugToMeta für Slug → Anzeigename; Objekte mit name/_slug werden unterstützt.
*/
function resolveFragmentTags(
tagsRaw: unknown[] | undefined,
slugToMeta: Map<string, TagMeta>,
): string[] {
if (!Array.isArray(tagsRaw)) return [];
return tagsRaw
.map((t) => {
if (typeof t === "string") return slugToMeta.get(t)?.name ?? t;
const o = t as { _slug?: string; name?: string };
const slug = o._slug ?? "";
return (o.name ?? slugToMeta.get(slug)?.name ?? slug).trim();
})
.filter(Boolean);
}
/**
* Lädt für alle searchable_text-Blöcke im Layout die textFragments nach
* (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und
* löst Tag-Slugs/Refs in Anzeigenamen auf.
* @param slugToMeta Optionale Map Slug → TagMeta (z. B. von resolvePostOverviewBlocks); sonst getTagsMap().
*/
export async function resolveSearchableTextBlocks(
layout: RowContentLayout | null | undefined,
slugToMeta?: Map<string, TagMeta>,
): Promise<void> {
if (!layout) return;
const tagsMap = slugToMeta ?? (await getTagsMap());
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isSearchableTextBlock(item)) continue;
const raw = item.textFragments ?? [];
if (!Array.isArray(raw) || raw.length === 0) continue;
const needsResolve = raw.some((f) => !isResolvedFragment(f));
if (!needsResolve) continue;
const resolved: unknown[] = [];
for (const f of raw) {
const slug =
typeof f === "string" ? f : ((f as { _slug?: string })?._slug ?? "");
if (!slug) {
if (isResolvedFragment(f)) resolved.push(f);
continue;
}
const frag = await getTextFragmentBySlug(slug, { locale: "de" });
if (frag) {
const fragObj = frag as { tags?: unknown[] };
const tagNames = resolveFragmentTags(fragObj.tags, tagsMap);
resolved.push({ ...fragObj, tags: tagNames });
}
}
(item as { textFragments?: unknown[] }).textFragments = resolved;
}
}
}
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
/**
* 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: `npm run generate:api-types` (RustyCMS muss laufen).
*/
import type { components, operations } from "./cms-api.generated";
import { env } from "$env/dynamic/public";
const getBaseUrl = (): string => {
const url = env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || "http://localhost:3000";
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
};
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
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"];
/** 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;
info: { title: string; version: string };
paths: Record<string, unknown>;
components?: { schemas?: Record<string, unknown> };
};
let openApiCache: OpenApiSpec | null = null;
/**
* TTL-Cache pro Collection (ms). Webhooks purgen bei Mutation → hohe TTL unkritisch.
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/
const CACHE_TTL: Record<string, number> = {
page: 5 * 60_000,
page_config: 10 * 60_000,
navigation: 10 * 60_000,
footer: 10 * 60_000,
post: 60_000,
tag: 5 * 60_000,
text_fragment: 5 * 60_000,
fullwidth_banner: 5 * 60_000,
calendar: 60_000,
calendar_item: 60_000,
translation: 5 * 60_000,
translation_bundle: 5 * 60_000,
openapi: 10 * 60_000,
default: 60_000,
};
type CacheEntry<T> = { value: T; expires: number };
const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
function ttlFor(collection: string): number {
return CACHE_TTL[collection] ?? CACHE_TTL.default;
}
/**
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
* Bei Fehler wird nichts gecacht.
*/
async function cached<T>(
collection: string,
key: string,
fn: () => Promise<T>,
): Promise<T> {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T;
const pending = inflight.get(key) as Promise<T> | undefined;
if (pending) return pending;
const promise = (async () => {
try {
const value = await fn();
cache.set(key, { value, expires: Date.now() + ttlFor(collection) });
return value;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
return promise;
}
/** Purge Cache für eine Collection (z.B. nach Webhook). */
export function invalidateCollection(collection: string): number {
let n = 0;
for (const k of cache.keys()) {
if (k.startsWith(`${collection}:`)) {
cache.delete(k);
n++;
}
}
return n;
}
/** Purge kompletter Cache. */
export function invalidateAll(): void {
cache.clear();
openApiCache = null;
}
/** Stats/Debug. */
export function cacheStats(): { size: number; keys: string[] } {
return { size: cache.size, keys: [...cache.keys()] };
}
/**
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
* Wird gecacht (einmal pro Request/Build).
*/
export async function fetchOpenApi(): Promise<OpenApiSpec> {
if (openApiCache) return openApiCache;
const base = getBaseUrl();
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}?`,
);
}
const spec = (await res.json()) as OpenApiSpec;
openApiCache = spec;
return spec;
}
/**
* Alle Page-Einträge (GET /api/content/page). TTL-gecacht.
*/
export async function getPages(): Promise<PageEntry[]> {
return cached("page", "page:list:", 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 ?? [];
});
}
/**
* Alle page_config-Einträge (GET /api/content/page_config).
*/
export async function getPageConfigs(options?: {
locale?: string;
per_page?: number;
}): Promise<PageConfigEntry[]> {
const locale = options?.locale ?? "";
const per = options?.per_page ?? 50;
const key = `page_config:list:${locale}:${per}`;
return cached("page_config", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/page_config`);
if (locale) url.searchParams.set("_locale", locale);
url.searchParams.set("_per_page", String(per));
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as PageConfigListResponse;
return (data.items ?? []) as PageConfigEntry[];
});
}
/**
* Page-Config anhand Slug (z. B. "default"). Gecacht pro Request (Key: slug + options).
*/
export async function getPageConfigBySlug(
slug: string,
options?: { locale?: string; resolve?: string },
): Promise<PageConfigEntry | null> {
const opts = options ?? {};
const key = `page_config:slug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
return cached("page_config", key, 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;
});
}
/**
* Startseiten-Slug aus page_config.
*/
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).
*/
export async function getPageBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<PageEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("page", key, async () => {
const base = getBaseUrl();
const trySlug = async (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}".`,
);
}
return (await res.json()) as PageEntry;
});
}
/** 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) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
}
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). */
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 Request.
*/
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 = `navigation:list:${opts.locale ?? ""}:${opts.page ?? 1}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
return cached("navigation", key, 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,
};
});
}
/** Keys für Navigation. */
export const NavigationKeys = {
header: "navigation-header",
socialMedia: "navigation-social-media",
footer: "navigation-footer",
} as const;
/**
* Navigation anhand Key/Slug aus der Liste holen.
*/
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.
*/
export async function getNavigationBySlug(
slug: string,
options?: { locale?: string },
): Promise<NavigationEntry | null> {
const byKey = await getNavigationByKey(slug, options);
if (byKey) return byKey;
const key = `navigation:slug:${slug}:${options?.locale ?? ""}`;
return cached("navigation", key, async () => {
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").
*/
export async function getFooterBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<FooterEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `footer:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("footer", key, async () => {
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"];
/** 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).
*/
export async function getPostBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<PostEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const trySlug = async (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 Request. */
export async function getPosts(
options?: GetPostsOptions,
): Promise<PostEntry[]> {
const opts = options ?? {};
const resolveVal = Array.isArray(opts.resolve)
? opts.resolve.join(",")
: (opts.resolve ?? "");
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
url.searchParams.set("_per_page", "1000");
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 ?? [];
});
}
/** Tag-Eintrag (z. B. für Blog-Filter). */
export type TagEntry = components["schemas"]["tag"];
/** Alle Tags (GET /api/content/tag). TTL-gecacht. */
export async function getTags(): Promise<TagEntry[]> {
return cached("tag", "tag:list:", 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 ?? [];
});
}
/** 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 key = `text_fragment:slug:${slug}:${options?.locale ?? ""}`;
return cached("text_fragment", key, async () => {
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 key = `fullwidth_banner:slug:${slug}:${options?.locale ?? ""}`;
return cached("fullwidth_banner", key, async () => {
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;
});
}
/** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */
export type TranslationEntry = { _slug?: string; text: string };
/** Translation anhand Slug (GET /api/content/translation/:slug). */
export async function getTranslationBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationEntry | null> {
const key = `translation:slug:${slug}:${options?.locale ?? ""}`;
return cached("translation", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation/${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 TranslationEntry;
});
}
/** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */
export type TranslationBundleEntry = {
_slug?: string;
strings: Record<string, string>;
};
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
*/
export async function getTranslationBundleBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationBundleEntry | null> {
const key = `translation_bundle:slug:${slug}:${options?.locale ?? ""}`;
return cached("translation_bundle", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
);
url.searchParams.set("_resolve", "all");
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 TranslationBundleEntry;
});
}
/** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"];
/** Kalender-Item (calendar_item) aus OpenAPI-Spec. */
export type CalendarItemEntry = components["schemas"]["calendar_item"];
/** Kalender anhand Slug (GET /api/content/calendar/:slug). */
export async function getCalendarBySlug(
slug: string,
options?: { locale?: string; resolve?: string },
): Promise<CalendarEntry | null> {
const key = `calendar:slug:${slug}:${options?.locale ?? ""}:${options?.resolve ?? ""}`;
return cached("calendar", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarEntry;
});
}
/** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */
export async function getCalendarItemBySlug(
slug: string,
options?: { locale?: string },
): Promise<CalendarItemEntry | null> {
const key = `calendar_item:slug:${slug}:${options?.locale ?? ""}`;
return cached("calendar_item", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar_item/${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 CalendarItemEntry;
});
}
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
label,
open = false,
id = undefined,
class: cls = "",
children,
}: {
label: string;
open?: boolean;
id?: string;
class?: string;
children: Snippet;
} = $props();
</script>
<details {id} {open} class="pt-4 border-t border-zinc-200 group {cls}">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-medium select-none">
<Icon icon="mdi:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
{label}
</summary>
<div class="mt-4">
{@render children()}
</div>
</details>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
color,
class: className = "",
}: { children?: Snippet; color?: string; class?: string } = $props();
function colorClasses(c?: string): string {
switch (c) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
default: return "bg-stein-100 text-stein-600 border border-stein-200";
}
}
</script>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {colorClasses(color)} {className}">
{@render children?.()}
</span>
+301
View File
@@ -0,0 +1,301 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import PostCard from "./PostCard.svelte";
import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte";
import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface TagEntry {
_slug?: string;
name: string;
icon?: string;
color?: string;
}
type SearchIndexEntry = {
slug: string;
headline: string;
excerpt: string;
subheadline: string;
tags: string[];
image: string | null;
created: string | null;
isEvent: boolean;
eventDate: string | null;
};
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
let {
posts = [],
tags = [],
activeTag = null,
currentPage = 1,
totalPages = 1,
totalPosts = 0,
basePath = "/posts",
translations = {},
upcomingEvents = [],
searchIndex = null,
}: {
posts: PostEntry[];
tags: TagEntry[];
activeTag: string | null;
currentPage: number;
totalPages: number;
totalPosts?: number;
basePath?: string;
translations?: Translations | null;
upcomingEvents?: EventPost[];
searchIndex?: SearchIndexEntry[] | null;
} = $props();
let query = $state("");
let year = $state<string>("");
const normalizedQuery = $derived(query.trim().toLowerCase());
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
function yearOf(entry: SearchIndexEntry): string {
const iso = entry.created ?? "";
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : String(d.getFullYear());
}
const availableYears = $derived.by<string[]>(() => {
if (!searchIndex) return [];
const set = new Set<string>();
for (const p of searchIndex) {
const y = yearOf(p);
if (y) set.add(y);
}
return Array.from(set).sort((a, b) => b.localeCompare(a));
});
const filteredResults = $derived.by<SearchIndexEntry[]>(() => {
if (!isFiltering || !searchIndex) return [];
const q = normalizedQuery;
return searchIndex
.filter((p) => {
if (year && yearOf(p) !== year) return false;
if (q) {
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
})
.slice(0, 80);
});
function searchResultHref(slug: string): string {
const norm = (slug ?? "").replace(/^\//, "").trim();
return norm ? `/post/${encodeURIComponent(norm)}` : "/post";
}
function formatEventDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return basePath;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
}
const totalCount = $derived(totalPosts);
</script>
<div class="blog-overview">
{#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm">
<span class="sr-only">{t(T.blog_search_label)}</span>
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:magnify" class="h-4 w-4" />
</span>
<input
type="search"
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
class="w-full rounded-sm border border-zinc-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
aria-label={t(T.blog_search_clear)}
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
{#if availableYears.length > 0}
<label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span>
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:calendar" class="h-4 w-4" />
</span>
<select
bind:value={year}
class="appearance-none rounded-sm border border-zinc-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
>
<option value="">{t(T.blog_year_all)}</option>
{#each availableYears as y}
<option value={y}>{y}</option>
{/each}
</select>
<span class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:chevron-down" class="h-4 w-4" />
</span>
</label>
{/if}
</div>
{/if}
{#if !isFiltering && upcomingEvents.length > 0}
<section class="mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4">
<h2 class="mb-3 flex items-center gap-1.5 text-sm font-semibold tracking-wide text-zinc-700 uppercase">
<Icon icon="mdi:calendar-clock" class="h-4 w-4" />
{t(T.blog_upcoming_events)}
</h2>
<ul class="space-y-2">
{#each upcomingEvents as ev}
<li>
<a href={postHref(ev)} class="group flex items-baseline gap-3 no-underline hover:underline">
<time class="shrink-0 text-xs font-medium text-zinc-600 tabular-nums">
{formatEventDate(ev.eventDate)}
</time>
<span class="text-sm text-zinc-900 group-hover:text-zinc-950">{ev.headline}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if isFiltering}
<div class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600">
<span>
{t(T.blog_results_count, { count: filteredResults.length })}
{#if query}{t(T.blog_results_for, { query })}{/if}
{#if year}{t(T.blog_results_in_year, { year })}{/if}
</span>
<button
type="button"
onclick={() => { query = ""; year = ""; }}
class="text-zinc-500 underline hover:text-zinc-900"
>{t(T.blog_filter_reset)}</button>
</div>
{#if filteredResults.length > 0}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredResults as hit}
<a href={searchResultHref(hit.slug)} class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all">
{#if hit.image}
<div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
<img src={hit.image} alt={hit.headline} class="h-full w-full object-cover" loading="lazy" />
</div>
{/if}
<div class="flex flex-1 flex-col p-4">
<h3 class="mb-1 text-base font-semibold">{hit.headline}</h3>
{#if hit.excerpt}
<p class="text-sm text-zinc-700 line-clamp-3">{hit.excerpt}</p>
{/if}
</div>
</a>
{/each}
</div>
{:else}
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
{t(T.blog_no_results)}
</div>
{/if}
{:else}
{#if tags.length > 0}
<div class="mb-2">
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
<Tag
label={t(T.blog_tag_all)}
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}
icon={tag.icon?.trim() || null}
customColor={activeTag === slug ? null : tag.color?.trim() || null}
/>
{/each}
</div>
</div>
{/if}
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post}
<PostCard
post={post}
href={postHref(post)}
tagBase={`${basePath}/tag`}
/>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2">
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="grow"></div>
<div class="bg-white rounded-md font-thin">
<div class="inline-flex text-xs p-2">
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
</div>
</div>
<div class="mt-4 text-xs text-zinc-600">
<a href="/posts/rss.xml" class="inline-flex items-center gap-1 hover:underline">
<Icon icon="mdi:rss" class="h-3.5 w-3.5" />
{t(T.blog_rss_subscribe)}
</a>
</div>
{/if}
</div>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
href,
target,
rel,
class: className = "",
}: {
children?: Snippet;
href?: string;
target?: string;
rel?: string;
class?: string;
} = $props();
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm min-w-0 overflow-hidden";
const interactive = $derived(
href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "",
);
</script>
{#if href}
<a {href} {target} {rel} class="{base} {interactive} {className}">
{@render children?.()}
</a>
{:else}
<div class="{base} {className}">
{@render children?.()}
</div>
{/if}
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { getCmsImageUrl } from '$lib/rusty-image';
interface Props {
/** Asset-Dateiname (z.B. "hero.jpg") oder volle URL. */
src: string;
width?: number;
height?: number;
/** Qualität 1100 (JPEG/WebP). */
quality?: number;
format?: 'jpeg' | 'png' | 'webp' | 'avif';
fit?: 'fill' | 'contain' | 'cover';
/** Seitenverhältnis, z.B. "16:9" oder "1:1". */
ar?: string;
alt?: string;
class?: string;
loading?: 'lazy' | 'eager';
}
let {
src,
width,
height,
quality,
format,
fit,
ar,
alt = '',
class: className = '',
loading = 'lazy',
}: Props = $props();
const resolvedSrc = $derived(
getCmsImageUrl(src, { width, height, quality, format, fit, ar }),
);
</script>
<img
src={resolvedSrc}
{alt}
width={width}
height={height}
class={className}
{loading}
/>
+154
View File
@@ -0,0 +1,154 @@
<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 CalendarBlock from "./blocks/CalendarBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
import type { BlockLayout } from "$lib/block-layout";
import type { Translations } from "$lib/translations";
import type {
RowContentLayout,
ResolvedBlock,
MarkdownBlockData,
HeadlineBlockData,
HtmlBlockData,
IframeBlockData,
ImageBlockData,
ImageGalleryBlockData,
YoutubeVideoBlockData,
QuoteBlockData,
LinkListBlockData,
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
} from "$lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $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} translations={translations} />
{: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} translations={translations} />
{:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
{:else if blockType(item) === "organisations"}
<OrganisationsBlock block={item as OrganisationsBlockData} />
{:else if blockType(item) === "opnform"}
<OpnFormBlock block={item as OpnFormBlockData} />
{: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)}
{@const rowBlockType = blockType(item)}
<div class="col-span-12">
<div
class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote'
? 'bg-wald-50'
: '[background:var(--color-container-breakout)]'}"
>
<div class="container-custom py-8">
{@render blockContent()}
</div>
</div>
</div>
{:else}
{@render blockContent()}
{/if}
{/if}
{/each}
</div>
{/if}
{/each}
</div>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
eventDate = null,
locationText = null,
locationHref = null,
}: {
eventDate?: string | null;
locationText?: string | null;
locationHref?: string | null;
} = $props();
function formatEventDate(dateStr: string): string | null {
try {
return new Date(dateStr).toLocaleDateString("de-DE", {
weekday: "short", day: "2-digit", month: "short",
year: "numeric", hour: "2-digit", minute: "2-digit",
});
} catch { return null; }
}
const dateLabel = $derived(eventDate ? formatEventDate(eventDate) : null);
</script>
{#if dateLabel || locationText}
<div class="flex flex-wrap gap-2">
{#if dateLabel}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-wald-500 px-3 py-1 text-[.65rem] font-semibold text-white shadow-sm">
<Icon icon="mdi:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{dateLabel}
</span>
{/if}
{#if locationText}
{#if locationHref}
<a href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-neutral-700 transition-colors no-underline!">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText}
</a>
{:else}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50 px-3 py-1 text-[.65rem] font-medium shadow-sm">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText}
</span>
{/if}
{/if}
</div>
{/if}
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import Accordion from "./Accordion.svelte";
import { t, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let {
embedUrl = null,
linkUrl = null,
locationText = null,
label = "Karte",
translations = null,
}: {
embedUrl?: string | null;
linkUrl?: string | null;
locationText?: string | null;
label?: string;
translations?: Translations | null;
} = $props();
let overlayVisible = $state(true);
</script>
<Accordion id="map-section" {label} open class="mt-12">
<div class="overflow-hidden rounded-lg border border-wald-200 shadow-sm">
{#if embedUrl}
<div>
<div class="relative">
<iframe
src={embedUrl}
width="600"
height="300"
class="block w-full border-0"
loading="lazy"
title={locationText ? `Karte: ${locationText}` : "Veranstaltungsort"}
></iframe>
{#if overlayVisible}
<div
role="button"
tabindex="0"
class="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[1px] cursor-pointer select-none"
aria-label="Karte aktivieren"
onclick={() => { overlayVisible = false; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') overlayVisible = false; }}
>
<div class="flex items-center gap-2 rounded-full bg-white/90 px-4 py-2 text-xs font-medium text-zinc-700 shadow">
<Icon icon="mdi:cursor-default-click" class="size-4 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_activate)}
</div>
</div>
{/if}
</div>
{#if linkUrl}
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 bg-wald-50 text-wald-700 text-[.65rem] font-medium hover:bg-wald-100 transition-colors border-t border-wald-200"
>
<Icon icon="mdi:open-in-new" class="size-3.5 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_open_osm)}
</a>
{/if}
</div>
{:else if linkUrl}
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 bg-wald-50 text-wald-700 text-xs font-medium hover:bg-wald-100 transition-colors"
>
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
{locationText ?? t(translations, T.post_map_open_osm)}
</a>
{/if}
</div>
</Accordion>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import ContentRows from "./ContentRows.svelte";
import type { RowContentLayout } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface FooterEntry extends RowContentLayout {
id?: string;
}
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
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 text-xs">
<ContentRows layout={footer} translations={translations} />
</div>
{:else}
<p class="text-sm text-white/80 {justifyClass} flex">
{t(T.footer_copyright) !== T.footer_copyright
? t(T.footer_copyright)
: `© ${new Date().getFullYear()} Windwiderstand`}
</p>
{/if}
</div>
</footer>
{/if}
+248
View File
@@ -0,0 +1,248 @@
<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";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface NavLink {
href: string;
label: string;
}
interface SocialLink {
href: string;
icon: string;
label?: string;
}
let {
links = [],
socialLinks = [],
logoUrl = null,
logoSvgHtml = null,
translations = {},
}: {
links?: NavLink[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
logoSvgHtml?: string | null;
translations?: Translations | null;
} = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
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 border-b border-white/30 shadow-2xl backdrop-blur-md bg-linear-to-br from-stein-50 from-0% via-wald-100/90 via-42% to-himmel-100 to-100%"
>
<div class="flex items-center justify-between container-custom py-2">
<a
href="/"
class="logo flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
>
{#if logoSvgHtml}
<span class="logo-svg h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10" aria-hidden="true">{@html logoSvgHtml}</span>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-10 w-auto object-contain" />
{:else}
<span>{t(T.site_name_fallback)}</span>
{/if}
</a>
<!-- Desktop: horizontale Nav (ab lg) -->
<nav
class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="hover:animate-pulse transition-all text-xs tracking-wide"
>
{link.label}
</a>
{/each}
<div><span class="opacity-20">|</span></div>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center"
aria-label={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} aria-hidden="true" />
</a>
{/each}
<button
type="button"
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else}
<Icon icon="mdi:magnify" 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 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="mdi:close"
class="text-2xl text-error!"
aria-hidden="true"
/>
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
aria-controls="mobile-nav"
onclick={toggleMenu}
>
<span class="sr-only"
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</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-stein-0/10 bg-stein-900/98 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={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/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-stein-0/10"
>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
aria-label={social.label || t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon
icon={social.icon}
class="text-[1.5rem]"
aria-hidden="true"
/>
</a>
{/each}
</div>
{/if}
</nav>
</div>
{/if}
</header>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { get } from "svelte/store";
import { overlayOpen, overlayClose } from "$lib/overlay-store";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
function handleClick() {
get(overlayClose)?.();
}
</script>
{#if $overlayOpen}
<button
type="button"
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
aria-label={t(T.header_overlay_close)}
onclick={handleClick}
></button>
{/if}
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import '$lib/iconify-offline';
import Icon from "@iconify/svelte";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
let {
currentPage = 1,
totalPages = 1,
basePath = "/posts",
activeTag = null,
}: {
currentPage: number;
totalPages: number;
basePath?: string;
activeTag?: string | null;
} = $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 ?? null);
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 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 =
" bg-linear-to-b from-stein-50 to-stein-100 rounded-full h-6 w-6 mx-[2px] flex justify-center items-center text-stein-950 no-underline text-xs font-medium border border-stein-200 shadow-md";
</script>
{#if showPagination}
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
</a>
{/if}
{#each pageNumbers as num}
<a
href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
aria-current={num === currentPage ? 'page' : undefined}
>
{num}
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
</a>
{/if}
</div>
</div>
{/if}
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PostEntry } from "$lib/cms";
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
let {
post,
href,
tagBase = "/posts/tag",
}: {
post: PostWithImage;
href: string;
tagBase?: string;
} = $props();
const resolvedImg = $derived(post._resolvedImageUrl);
type PostWithEvent = PostWithImage & {
isEvent?: boolean;
eventDate?: string;
eventLocation?: { text?: string };
};
const p = post as PostWithEvent;
const eventDate = $derived(p.isEvent && p.eventDate ? p.eventDate : null);
const locationText = $derived(p.eventLocation?.text ?? null);
</script>
<a
href={href}
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white"
>
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
{#if resolvedImg}
<img
src={resolvedImg}
alt={post.headline ?? ""}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="size-12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
{/if}
{#if eventDate}
<div class="absolute bottom-2 left-2 right-2">
<EventBadges {eventDate} {locationText} />
</div>
{/if}
</div>
<div class="flex-1 flex flex-col justify-start p-4">
<PostMeta
date={post.created ?? null}
tags={post.postTag ?? []}
{tagBase}
/>
<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>
+29
View File
@@ -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>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/**
* Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…).
*/
interface Props {
/** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */
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}
/>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { tick } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
type SearchHit = {
type: "page" | "post";
slug: string;
href: string;
title: string;
subtitle: string;
excerpt: string;
image: string | null;
created: string | null;
isEvent?: boolean;
eventDate?: string | null;
};
let { open = $bindable(false) } = $props();
let query = $state("");
let allHits = $state<SearchHit[] | null>(null);
let loading = $state(false);
let loadError = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
const normalizedQuery = $derived(query.trim().toLowerCase());
const results = $derived.by<SearchHit[]>(() => {
if (!allHits || normalizedQuery.length < 2) return [];
const q = normalizedQuery;
return allHits
.filter((h) => {
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 40);
});
async function loadIndex() {
if (allHits || loading) return;
loading = true;
loadError = null;
try {
const res = await fetch("/api/search-index");
if (!res.ok) throw new Error("search index fetch failed");
const data = (await res.json()) as { hits: SearchHit[] };
allHits = data.hits ?? [];
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
allHits = [];
} finally {
loading = false;
}
}
function closeSearch() {
open = false;
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
$effect(() => {
if (!open) return;
void loadIndex();
tick().then(() => inputEl?.focus());
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSearch();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
</script>
{#if open}
<div
id="search-panel"
class="border-t border-white/30 bg-white/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)]">
<label class="relative block max-w-xl mx-auto">
<span class="sr-only">Suche</span>
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:magnify" class="h-5 w-5" />
</span>
<input
bind:this={inputEl}
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-sm border border-zinc-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
aria-label="Eingabe leeren"
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
<div class="max-w-xl mx-auto mt-4 text-sm">
{#if loading}
<p class="text-zinc-500 text-center py-2">Lade Suchindex…</p>
{:else if loadError}
<p class="text-red-600 text-center py-2">Suche nicht verfügbar: {loadError}</p>
{:else if normalizedQuery.length < 2}
<p class="text-zinc-500 text-center py-2">Mindestens 2 Zeichen eingeben.</p>
{:else if results.length === 0}
<p class="text-zinc-500 text-center py-2">Keine Treffer.</p>
{:else}
<ul class="divide-y divide-zinc-200">
{#each results as hit}
<li>
<a
href={hit.href}
onclick={closeSearch}
class="flex items-start gap-3 py-3 hover:bg-zinc-50 rounded-sm px-2 -mx-2 no-underline"
>
{#if hit.image}
<img
src={hit.image}
alt=""
class="w-20 h-12 object-cover shrink-0 rounded-sm border border-zinc-200"
loading="lazy"
/>
{:else}
<span class="w-20 h-12 shrink-0 bg-zinc-100 rounded-sm border border-zinc-200 flex items-center justify-center text-zinc-400">
<Icon icon={hit.type === "post" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" />
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-zinc-500">
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
{#if hit.isEvent && hit.eventDate}
<span class="text-wald-700">{formatDate(hit.eventDate)}</span>
{:else if hit.created}
<span>{formatDate(hit.created)}</span>
{/if}
</div>
<div class="font-medium text-zinc-900 truncate">{hit.title}</div>
{#if hit.excerpt}
<div class="text-xs text-zinc-600 line-clamp-2">{hit.excerpt}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/if}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
type Variant = "white" | "green" | "blue" | "inactive" | "date";
let {
label,
variant = "white",
href = null,
active = false,
onclick = null,
icon = null,
customColor = null,
}: {
label: string;
variant?: Variant;
href?: string | null;
active?: boolean;
onclick?: (() => void) | null;
icon?: string | null;
customColor?: string | null;
} = $props();
function isLightBackground(cssColor: string): boolean {
const s = cssColor.trim();
const hex6 = /^#([0-9a-f]{6})$/i.exec(s);
const hex3 = /^#([0-9a-f]{3})$/i.exec(s);
let r = 200;
let g = 200;
let b = 200;
if (hex6) {
const n = parseInt(hex6[1], 16);
r = (n >> 16) & 255;
g = (n >> 8) & 255;
b = n & 255;
} else if (hex3) {
const h = hex3[1];
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (s.startsWith("rgb")) {
const m = s.match(/\d+/g);
if (m && m.length >= 3) {
r = Number(m[0]);
g = Number(m[1]);
b = Number(m[2]);
}
} else {
return true;
}
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55;
}
const baseClass =
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0";
const variantClass = $derived.by(() => {
if (customColor?.trim()) {
return "";
}
if (variant === "white")
return "bg-stein-100 text-stein-800";
if (variant === "green") return "bg-wald-100 text-wald-800";
if (variant === "blue") return "bg-sky-100/90 text-sky-900";
if (variant === "inactive") return "bg-stein-200 text-stein-500";
if (variant === "date") return "bg-wald-50 text-wald-800";
return "bg-stein-200 text-stein-500";
});
const pillStyle = $derived.by(() => {
const c = customColor?.trim();
if (!c) return "";
const light = isLightBackground(c);
const fg = light ? "#111827" : "#fafafa";
return `background-color: ${c}; color: ${fg};`;
});
const interactiveClass = $derived.by(() =>
href || onclick ? "hover:brightness-[0.97] active:brightness-[0.94]" : "",
);
const classes = $derived.by(
() =>
`${baseClass} ${variantClass} ${interactiveClass} ${active ? "pointer-events-none opacity-80" : ""}`.trim(),
);
</script>
{#if href}
<a
href={href}
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
onclick={onclick}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</button>
{:else}
<span class={classes} style={pillStyle || undefined}>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</span>
{/if}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import Tag from "./Tag.svelte";
type TagItem =
| string
| { name?: string; _slug?: string; icon?: string; color?: 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 ?? ""
);
}
function tagIcon(t: TagItem): string | null {
if (typeof t === "string") return null;
const i = (t as { icon?: string }).icon?.trim();
return i || null;
}
function tagColor(t: TagItem): string | null {
if (typeof t === "string") return null;
const c = (t as { color?: string }).color?.trim();
return c || null;
}
</script>
{#each (tags ?? []) as t}
{@const name = displayName(t)}
{#if name}
<Tag
label={name}
variant={variant}
href={tagBase ? `${tagBase}/${slug(t)}` : null}
icon={tagIcon(t)}
customColor={tagColor(t)}
/>
{/if}
{/each}
+80
View File
@@ -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 { setContext } from "svelte";
import type { Snippet } from "svelte";
import type { Translations, TranslationReplacements } from "$lib/translations";
import { T_CONTEXT_KEY, replacePlaceholders } from "$lib/translations";
let {
translations = {},
children,
}: {
translations?: Translations;
children?: Snippet;
} = $props();
const t = (key: string, replacements?: TranslationReplacements): string => {
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
};
setContext(T_CONTEXT_KEY, t);
</script>
{#if children}
{@render children()}
{/if}
@@ -0,0 +1,349 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
CalendarBlockData,
CalendarItemData,
} from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
type EventCardItem = CalendarItemData & {
date: Date | null;
timeStr: string;
};
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const items = $derived.by(() => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => ({
...x,
date: parseDate(x.terminZeit),
timeStr: formatTime(x.terminZeit),
}))
.filter((x) => x.date)
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
});
function parseDate(iso: string): Date | null {
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
function getEventLinkHref(link: unknown): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
const o = link as { url?: string; _slug?: string };
if (typeof o.url === "string" && o.url) return o.url;
if (typeof o._slug === "string" && o._slug)
return `/post/${encodeURIComponent(o._slug)}`;
}
return "";
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const eventsByDay = $derived.by(() => {
const map = new Map<string, typeof items>();
for (const item of items) {
if (!item.date) continue;
const key = dayKey(item.date);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return map;
});
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
const year = $derived(currentMonth.getFullYear());
const month = $derived(currentMonth.getMonth());
const monthLabel = $derived(
currentMonth.toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
);
const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const count = last.getDate();
const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last };
});
const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null);
const days = Array.from(
{ length: count },
(_, i) => new Date(year, month, i + 1),
);
return [...empty, ...days];
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
/** Nächste 3 Termine ab heute zur Laufzeit gefiltert. */
const nextUpcomingEvents = $derived.by(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
return items
.filter((item) => item.date && item.date.getTime() >= todayMs)
.slice(0, 3);
});
function prevMonth() {
currentMonth = new Date(year, month - 1);
selectedDay = null;
}
function nextMonth() {
currentMonth = new Date(year, month + 1);
selectedDay = null;
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
const todayKey = $derived(dayKey(new Date()));
const weekdays = $derived([
t(T.calendar_weekday_mo),
t(T.calendar_weekday_di),
t(T.calendar_weekday_mi),
t(T.calendar_weekday_do),
t(T.calendar_weekday_fr),
t(T.calendar_weekday_sa),
t(T.calendar_weekday_so),
]);
</script>
<div
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
data-block-type="calendar"
data-block-slug={block._slug}
>
{#snippet eventCard(item: EventCardItem)}
{@const eventHref = getEventLinkHref(item.link)}
<li class="bg-himmel-100 p-2">
<div class="flex gap-1">
{#if item.date}
<div class="text-xs text-stein-500 mb-0.5">
{item.date.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
})}
</div>
{#if item.timeStr}
<span class="text-xs text-stein-600">/</span>
{/if}
{/if}
{#if item.timeStr}
<div class="text-xs text-stein-600">
{item.timeStr}
{t(T.calendar_time_suffix)}
</div>
{/if}
</div>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
>
{item.title}
</a>
{:else}
<div class="text-base font-medium text-stein-900">{item.title}</div>
{/if}
{#if item.description}
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
{item.description}
</p>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
{:else if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
</li>
{/snippet}
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
{block.title}
</h3>
{/if}
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
<!-- Links: Kalender -->
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
<div class="flex items-center justify-between gap-2 mb-4">
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
</button>
<span
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
{#each weekdays as w}
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
{/each}
{#each calendarDays as d}
{#if d === null}
<div class="aspect-square" aria-hidden="true"></div>
{:else}
{@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const events = eventsByDay.get(key) ?? []}
{@const hasEvents = events.length > 0}
{@const isSelected = selectedDay === key}
{@const isFuture = key >= todayKey}
{#if hasEvents}
<button
type="button"
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
? 'text-himmel-100 hover:bg-himmel-700'
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
? 'ring-2 ring-himmel-400 bg-himmel-600'
: ''}"
onclick={() => selectDay(key)}
>
<span class="text-xs">{d.getDate()}</span>
<span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
aria-hidden="true"
>
{events.length}
</span>
</button>
{:else}
<div
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
? 'text-himmel-100'
: 'text-himmel-100 opacity-50'}"
>
<span class="text-xs">{d.getDate()}</span>
</div>
{/if}
{/if}
{/each}
</div>
</div>
<!-- Rechts: Events -->
<div class="calendar-events-wrapper min-h-0">
<div
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
>
{#if items.length === 0}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
{:else if selectedDay}
<p class="text-xs font-medium text-stein-500 mb-2">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
})}
</p>
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
{#each selectedDayEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else if nextUpcomingEvents.length > 0}
<p class="text-xs uppercase font-medium text-stein-500">
{t(T.calendar_next_events)}
</p>
<ul
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
>
{#each nextUpcomingEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
{/if}
</div>
</div>
</div>
</div>
@@ -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,67 @@
<script lang="ts">
import '$lib/iconify-offline';
import Icon from "@iconify/svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { IframeBlockData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen. */
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={t(T.iframe_activate_aria)}
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">{t(T.iframe_missing)}</p>
{/if}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ImageBlockData } from "$lib/block-types";
import CmsImage from "$lib/components/CmsImage.svelte";
let { block }: { block: ImageBlockData } = $props();
/** Rohe Bild-Quelle für /cms-images (URL, //-URL oder Asset-Dateiname/Slug). */
function getRawSrc(b: ImageBlockData): string | null {
const img = b.img ?? b.image;
if (typeof img === "string") {
const s = img.trim();
if (!s) return null;
return s.startsWith("//") ? `https:${s}` : s;
}
if (img && typeof img === "object") {
const src =
(img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
if (typeof src === "string" && src)
return src.startsWith("//") ? `https:${src}` : src;
}
return null;
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const imageSource = $derived(block.image ?? block.img);
const rawSrc = $derived(getRawSrc(block));
$effect(() => {
if (!rawSrc && block._slug) {
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
hasImage: !!block.image,
hasImg: !!block.img,
imageType: typeof block.image,
imgType: typeof block.img,
imgSrc:
block.img && typeof block.img === "object"
? (block.img as { src?: string }).src
: undefined,
imageSrc:
block.image && typeof block.image === "object"
? (block.image as { src?: string }).src
: undefined,
});
}
});
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 rawSrc}
<figure class="max-w-[400px]">
<CmsImage
src={rawSrc}
width={800}
format="webp"
quality={85}
fit="contain"
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,216 @@
<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";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
import CmsImage from "$lib/components/CmsImage.svelte";
const t = useTranslate();
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) => {
const url = imageUrl(img);
return url != null ? { img, url } : null;
})
.filter((x): x is { img: ImageGalleryImage; url: string } => x != null),
);
let currentIndex = $state(0);
function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") {
const s = img.src.trim();
if (!s) return null;
return s.startsWith("//") ? `https:${s}` : s;
}
if (img.file?.url) {
const u = img.file.url;
return u.startsWith("//") ? `https:${u}` : u;
}
return null;
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
}
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
}
const SWIPE_THRESHOLD = 50;
let startX = $state(0);
let isDragging = $state(false);
let dragDelta = $state(0);
function onPointerDown(e: PointerEvent) {
if (withUrl.length <= 1) return;
startX = e.clientX;
isDragging = true;
dragDelta = 0;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
dragDelta = e.clientX - startX;
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
}
function onPointerUp() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
function onTouchStart(e: TouchEvent) {
if (withUrl.length <= 1) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
function onTouchMove(e: TouchEvent) {
if (!isDragging || !e.touches[0]) return;
const delta = e.touches[0].clientX - startX;
dragDelta = delta;
if (Math.abs(delta) > 10) e.preventDefault();
}
function onTouchEnd() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
</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-stein-500">{t(T.image_gallery_empty)}</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<CmsImage
src={withUrl[0].url}
width={1280}
height={720}
fit="cover"
format="webp"
quality={80}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
/>
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
{/if}
</figure>
{:else}
<div class="group relative">
<div
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
role="region"
aria-label={t(T.image_gallery_swipe_aria)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchmove={onTouchMove}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
>
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
class="m-0 absolute inset-0"
transition:fade={{ duration: 200 }}
>
<CmsImage
src={current.url}
width={1280}
height={720}
fit="cover"
format="webp"
quality={80}
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-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
>
{current.img.description}
</figcaption>
{/if}
</figure>
{/key}
</div>
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_prev_aria)}
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-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_next_aria)}
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={t(T.image_gallery_position_aria)}>
{#each withUrl as _, i}
<button
type="button"
role="tab"
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
aria-selected={i === currentIndex}
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
? 'bg-wald-600'
: 'bg-stein-300 hover:bg-stein-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,36 @@
<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.resolvedContent ??
(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,99 @@
<script lang="ts">
import { onMount } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OpnFormBlockData } from "$lib/block-types";
let { block }: { block: OpnFormBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const formId = $derived((block.formId ?? "").trim());
const baseUrl = $derived(
(typeof block.baseUrl === "string" && block.baseUrl.trim()
? block.baseUrl.trim()
: "https://opnform.pm86.de"
).replace(/\/$/, ""),
);
const iframeSrc = $derived(formId ? `${baseUrl}/forms/${formId}` : "");
const iframeTitle = $derived(block.iframeTitle?.trim() || "Kontaktformular");
let iframeEl = $state<HTMLIFrameElement | null>(null);
onMount(() => {
if (!formId || !iframeEl) return;
const el = iframeEl;
el.style.height = "600px";
el.style.transition = "height 0.25s ease";
el.setAttribute("scrolling", "no");
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
function handleMessage(event: MessageEvent) {
if (event.source !== el.contentWindow) return;
const d = event.data;
let h: number | undefined;
if (typeof d === "number") {
h = d;
} else if (d && typeof d === "object") {
h = d.height ?? d.frameHeight ?? d.iFrameHeight;
} else if (typeof d === "string" && d.startsWith("[iFrameSizer]")) {
const parts = d.split(":");
h = parts.length >= 2 ? parseFloat(parts[1]) : undefined;
}
if (typeof h === "number" && h > 50) {
el.style.height = `${Math.ceil(h)}px`;
}
}
window.addEventListener("message", handleMessage);
function runInit() {
const w = window as Window & {
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
};
w.initEmbed?.(formId, { autoResize: true });
}
const existing = document.querySelector<HTMLScriptElement>(
`script[data-opnform-embed-js="${baseUrl}"]`,
);
const w = window as Window & { initEmbed?: unknown };
if (existing) {
if (typeof w.initEmbed === "function") {
queueMicrotask(runInit);
} else {
existing.addEventListener("load", runInit, { once: true });
}
} else {
const s = document.createElement("script");
s.type = "text/javascript";
s.async = true;
s.src = scriptUrl;
s.dataset.opnformEmbedJs = baseUrl;
s.onload = () => runInit();
document.body.appendChild(s);
}
return () => {
window.removeEventListener("message", handleMessage);
};
});
</script>
<div
class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4"
data-block-type="opnform"
data-block-slug={block._slug}
>
{#if iframeSrc}
<iframe
bind:this={iframeEl}
style="border: none; width: 100%;"
id={formId}
title={iframeTitle}
src={iframeSrc}
></iframe>
{:else}
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
{/if}
</div>
@@ -0,0 +1,231 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
import { absoluteUrlFromCmsImageField, getCmsImageUrl } from "$lib/rusty-image";
import Card from "$lib/components/Card.svelte";
import Badge from "$lib/components/Badge.svelte";
marked.setOptions({ gfm: true });
/** Nach resolveContentImages: resolvedLogoSrc; sonst /cms-images Proxy. */
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
const resolved = o.resolvedLogoSrc?.trim();
if (resolved) return resolved;
const url = absoluteUrlFromCmsImageField(o.logo);
if (!url) return undefined;
return getCmsImageUrl(url, { width: 256, fit: 'contain', format: 'webp', quality: 85 });
}
function organisationLogoAlt(o: OrganisationEntry): string {
if (o.logo && typeof o.logo === "object" && o.logo.description) {
return o.logo.description;
}
return o.name ?? "";
}
let { block }: { block: OrganisationsBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
const compact = $derived(block.presentation === "compact");
const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived(
block.addOrganisationMarkdown
? (marked.parse(block.addOrganisationMarkdown) as string)
: ""
);
const addOrganisationCtaLink = $derived.by(() => {
const raw = block.addOrganisationLink;
if (!raw || typeof raw !== "object") return undefined;
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return undefined;
const linkName =
typeof raw.linkName === "string" && raw.linkName.trim() !== ""
? raw.linkName.trim()
: url;
return { url, linkName, newTab: Boolean(raw.newTab) };
});
const showAddOrganisationCard = $derived(
Boolean(
block.addOrganisationTitle?.trim() ||
block.addOrganisationMarkdown?.trim() ||
addOrganisationCtaLink
)
);
const addOrganisationCardClass = $derived(
`${compact ? "border-dashed border-wald-300 bg-wald-50! p-3! gap-1.5!" : "border-dashed border-wald-300 bg-wald-50!"}${addOrganisationCtaLink ? " cursor-pointer" : ""}`,
);
</script>
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline}
<h3 class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}>{block.headline}</h3>
{/if}
<div
class={compact
? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3"
: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoSrc = organisationLogoSrc(o)}
{#if compact}
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
<div
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg
class="h-7 w-7 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
<div class="min-w-0 flex-1 flex flex-col gap-0.5">
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
{/if}
</div>
</Card>
{:else}
<Card href={linkUrl}>
<div
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-1"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg
class="h-10 w-10 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<div class="absolute top-3 right-3">
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="min-w-0 text-sm font-semibold leading-snug text-stein-900 truncate">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-sm text-stein-600">{o.description}</p>
{/if}
</Card>
{/if}
{/if}
{/each}
{#if showAddOrganisationCard}
<Card
href={addOrganisationCtaLink?.url}
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class={addOrganisationCardClass}
>
{#if block.addOrganisationTitle}
<h5
class={compact
? "text-sm font-semibold text-wald-800 leading-snug"
: "text-base font-semibold text-wald-800 leading-snug"}
>
{block.addOrganisationTitle}
</h5>
{/if}
{#if ctaHtml}
<div class={compact ? "markdown text-xs text-wald-700" : "markdown text-sm text-wald-700"}>
{@html ctaHtml}
</div>
{/if}
{#if addOrganisationCtaLink}
<p
class={compact
? "mb-0 mt-1 text-xs font-medium text-wald-800"
: "mb-0 mt-2 text-sm font-medium text-wald-800"}
>
{addOrganisationCtaLink.linkName}
</p>
{/if}
</Card>
{/if}
</div>
</div>
@@ -0,0 +1,59 @@
<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";
import { useTranslate, T } from "$lib/translations";
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
const t = useTranslate();
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}
{#if posts.length > 0}
<div class="mt-3 text-xs px-2">
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline !no-underline">
{t(T.post_overview_all)}
<span aria-hidden="true"></span>
</a>
</div>
{/if}
</div>
@@ -0,0 +1,18 @@
<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,324 @@
<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";
import Tooltip from "$lib/ui/Tooltip.svelte";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let {
block,
class: className = "",
translations = {},
}: {
block: SearchableTextBlockData;
class?: string;
translations?: Translations | null;
} = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const helpTooltipText = $derived(t(T.searchable_text_help));
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");
let copiedIndex = $state<number | null>(null);
async function copyFragmentContent(
fragment: { title: string; text: string },
index: number,
) {
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(plain);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
try {
const ta = document.createElement("textarea");
ta.value = plain;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
/* ignore */
}
}
}
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-wald-200 text-wald-900 rounded px-0.5";
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>`,
);
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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-stein-200 bg-stein-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 text-stein-900 mb-2">
{block.title}
</h2>
{/if}
{#if descriptionHtml}
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
{@html descriptionHtml}
</div>
{/if}
</div>
<header
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
>
<div class="flex flex-col gap-2">
<div class="relative flex items-center gap-2">
<Tooltip content={helpTooltipText} placement="top">
<button
type="button"
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
aria-label={t(T.searchable_text_help_aria)}
>
<Icon
icon="mdi:help-circle-outline"
class="size-5"
aria-hidden="true"
/>
</button>
</Tooltip>
<input
type="search"
placeholder={t(T.searchable_text_placeholder)}
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label={t(T.searchable_text_search_aria)}
/>
{#if searchQuery}
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
aria-label={t(T.searchable_text_clear_search)}
>
</button>
{/if}
<Icon
icon="mdi:magnify"
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-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-wrap min-w-max md:flex-wrap md:w-auto">
<Tag
label={t(T.searchable_text_tag_all)}
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-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
aria-live="polite"
>
{#if visibleFragments.length === 0}
<span>{t(T.searchable_text_no_results)}</span>
{:else}
<span>
{visibleFragments.length === fragments.length
? t(T.searchable_text_count_all, { count: visibleFragments.length })
: t(T.searchable_text_count_filtered, {
visible: visibleFragments.length,
total: fragments.length,
})}
</span>
{/if}
</div>
<div class="flex flex-col gap-0 bg-stein-100">
{#each visibleFragments as fragment, i}
<details class="group border-b border-stein-200 last:border-b-0">
<summary
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
>
<div class="flex items-start gap-2 min-w-0">
<div class="grow min-w-0">
{@html highlightInText(
fragment.title || t(T.searchable_text_untitled),
searchQuery.trim(),
)}
</div>
<Icon
icon="mdi:chevron-down"
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
aria-hidden="true"
/>
</div>
{#if fragment.tags.length > 0}
<span class="flex flex-wrap gap-1">
<Tags tags={fragment.tags} variant="white" />
</span>
{/if}
</summary>
<div
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
>
<div class="flex justify-end mb-3 -mt-1">
<button
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
onclick={(e) => {
e.preventDefault();
copyFragmentContent(fragment, i);
}}
aria-label={t(T.searchable_text_copy_aria)}
>
{#if copiedIndex === i}
<Icon
icon="mdi:check"
class="size-4 text-wald-600"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copied)}</span>
{:else}
<Icon
icon="mdi:content-copy"
class="size-4"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copy_button)}</span>
{/if}
</button>
</div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
{/each}
</div>
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { YoutubeVideoBlockData } from "$lib/block-types";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
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-stein-100">
<iframe
title={block.title ?? t(T.youtube_title_fallback)}
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}
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
{/if}
{:else}
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
{/if}
</div>
+38
View File
@@ -0,0 +1,38 @@
/**
* 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";
/** Parameter für og:image / twitter:image (über /cms-images + Disk-Cache). */
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: "all" damit auch img in row1Content/Image-Blöcken aufgelöst wird (src), sonst nur Referenz. */
export const POST_RESOLVE = ["all"];
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
export const SITE_NAME = "Windwiderstand";
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
export const TRANSLATION_BUNDLE_SLUG = "app";
+12
View File
@@ -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);
+211
View File
@@ -0,0 +1,211 @@
/**
* Persistenter Disk-Cache für CMS-Bilder.
* Bilder werden einmal vom CMS geholt, auf Disk gespeichert und danach
* direkt von dort ausgeliefert kein erneuter CMS-Request nötig.
*
* Zwei Ebenen: In-Memory (LRU) für häufig angefragte Bilder,
* Disk für Persistenz über Neustarts hinweg.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import { readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const CACHE_DIR = process.env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
const MAX_MEMORY_ENTRIES = Number(process.env.IMAGE_CACHE_MAX_MEMORY) || 200;
export interface CachedImage {
buffer: Buffer;
contentType: string;
}
export interface ImageTransformParams {
w?: number;
h?: number;
q?: number;
format?: string;
fit?: string;
ar?: string;
}
const memoryCache = new Map<string, CachedImage>();
let resolvedDir: string | null = null;
function ensureCacheDir(): string {
if (resolvedDir) return resolvedDir;
const dir = join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
resolvedDir = dir;
return dir;
}
/**
* Deterministischer Cache-Key aus src + allen Transform-Parametern.
* Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge.
*/
export function buildCacheKey(src: string, params: ImageTransformParams): string {
const parts = [src];
if (params.w != null) parts.push(`w=${params.w}`);
if (params.h != null) parts.push(`h=${params.h}`);
if (params.q != null) parts.push(`q=${params.q}`);
if (params.format) parts.push(`f=${params.format}`);
if (params.fit) parts.push(`fit=${params.fit}`);
if (params.ar) parts.push(`ar=${params.ar}`);
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
}
function extForContentType(ct: string): string {
if (ct.includes('webp')) return 'webp';
if (ct.includes('avif')) return 'avif';
if (ct.includes('png')) return 'png';
if (ct.includes('svg')) return 'svg';
if (ct.includes('gif')) return 'gif';
return 'jpg';
}
/** Content-Type für HTTP-Header aus Dateiendung (eine Datei pro Cache-Eintrag). */
export function contentTypeFromExtension(ext: string): string {
switch (ext.toLowerCase()) {
case 'webp':
return 'image/webp';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'avif':
return 'image/avif';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
default:
return 'image/jpeg';
}
}
const IMAGE_FILE_EXT = new Set(['webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'svg']);
function rememberInMemory(key: string, cached: CachedImage): CachedImage {
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
const oldest = memoryCache.keys().next().value;
if (oldest !== undefined) memoryCache.delete(oldest);
}
memoryCache.set(key, cached);
return cached;
}
/**
* Liest einen Cache-Eintrag: eine Datei `{key}.{webp|jpg|…}` (ein I/O).
* Fallback: altes Paar `.meta` + `.bin`. Optional `params.format`, um die Endung vorab zu probieren.
*/
export async function getCachedImage(
key: string,
params?: ImageTransformParams,
): Promise<CachedImage | null> {
const mem = memoryCache.get(key);
if (mem) return mem;
const dir = ensureCacheDir();
const tryFile = async (ext: string): Promise<CachedImage | null> => {
try {
const buf = await readFile(join(dir, `${key}.${ext}`));
const cached: CachedImage = {
buffer: buf,
contentType: contentTypeFromExtension(ext),
};
return rememberInMemory(key, cached);
} catch {
return null;
}
};
if (params?.format) {
const ext = resolveExtension(params);
const hit = await tryFile(ext);
if (hit) return hit;
if (ext === 'jpg') {
const hitJpeg = await tryFile('jpeg');
if (hitJpeg) return hitJpeg;
}
}
try {
const files = await readdir(dir);
const prefix = `${key}.`;
const imageName = files.find((f) => {
if (!f.startsWith(prefix)) return false;
const rest = f.slice(prefix.length).toLowerCase();
if (rest === 'meta' || rest === 'bin') return false;
return IMAGE_FILE_EXT.has(rest);
});
if (imageName) {
try {
const buf = await readFile(join(dir, imageName));
const ext = imageName.slice(prefix.length);
return rememberInMemory(key, {
buffer: buf,
contentType: contentTypeFromExtension(ext),
});
} catch {
// weiter zu Legacy
}
}
} catch {
// ignore
}
const metaPath = join(dir, `${key}.meta`);
const dataPath = join(dir, `${key}.bin`);
try {
const [contentType, buf] = await Promise.all([
readFile(metaPath, 'utf8'),
readFile(dataPath),
]);
return rememberInMemory(key, { buffer: buf, contentType });
} catch {
return null;
}
}
export async function cacheImage(
key: string,
buffer: ArrayBuffer | Buffer,
contentType: string,
): Promise<CachedImage> {
const dir = ensureCacheDir();
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const ext = extForContentType(contentType);
const cached: CachedImage = { buffer: buf, contentType };
rememberInMemory(key, cached);
const base = join(dir, key);
try {
const files = await readdir(dir);
const prefix = `${key}.`;
for (const f of files) {
if (!f.startsWith(prefix)) continue;
await unlink(join(dir, f)).catch(() => {});
}
} catch {
await unlink(`${base}.meta`).catch(() => {});
await unlink(`${base}.bin`).catch(() => {});
}
await writeFile(join(dir, `${key}.${ext}`), buf);
return cached;
}
/**
* Dateiendung aus den Transform-Parametern oder Content-Type ableiten.
*/
export function resolveExtension(params: ImageTransformParams, contentType?: string): string {
if (params.format) return params.format === 'jpeg' ? 'jpg' : params.format;
if (contentType) return extForContentType(contentType);
return 'jpg';
}
+7
View File
@@ -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);
+203
View File
@@ -0,0 +1,203 @@
/**
* Bild-URLs für SvelteKit: /cms-images mit persistentem Server-Disk-Cache.
* Der Endpoint holt bei Bedarf vom CMS /api/transform und legt Bytes unter IMAGE_CACHE_DIR ab.
*/
/**
* Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL.
* Für Browser-seitigen Code (kein Node.js/fs nötig).
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
if (typeof field === 'string') {
const s = field.trim();
if (!s) return null;
return s.startsWith('//') ? `https:${s}` : s;
}
if (field && typeof field === 'object') {
const o = field as { src?: string; file?: { url?: string } };
const u =
typeof o.src === 'string' && o.src.trim()
? o.src
: typeof o.file?.url === 'string' && o.file.url.trim()
? o.file.url
: undefined;
if (!u) return null;
return u.startsWith('//') ? `https:${u}` : u;
}
return null;
}
export interface RustyImageTransformParams {
width?: number;
height?: number;
ar?: string;
fit?: 'fill' | 'contain' | 'cover';
format?: 'jpeg' | 'png' | 'webp' | 'avif';
quality?: number;
}
/**
* Lokale Bild-URL: /cms-images?src=&w=&q=
* `src` = volle Bild-URL oder CMS-Asset-Dateiname (wird serverseitig zu /api/assets/ aufgelöst).
*/
export function getCmsImageUrl(src: string, params: RustyImageTransformParams = {}): string {
const searchParams = new URLSearchParams();
searchParams.set('src', src);
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('q', String(params.quality));
return `/cms-images?${searchParams.toString()}`;
}
/**
* @deprecated Bevorzuge getCmsImageUrl gleiche Signatur, liefert /cms-images (Disk-Cache).
*/
export function getTransformUrl(url: string, params: RustyImageTransformParams = {}): string {
return getCmsImageUrl(url, params);
}
/** Alias für Kompatibilität (z. B. rustyastro / Layout). */
export async function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): Promise<string> {
return getCmsImageUrl(url, params);
}
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;
}
export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise<string[]> {
return items
.map(toImageUrl)
.filter((u): u is string => u !== null && u.startsWith('http'))
.map((u) => getCmsImageUrl(u, params));
}
export interface RowContentLayoutLike {
row1Content?: unknown[];
row2Content?: unknown[];
row3Content?: unknown[];
}
const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = {
width: 1200,
fit: 'contain',
format: 'webp',
quality: 85,
};
const IMG_SRC_REGEX = /<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
export async function processMarkdownHtmlImages(html: string, _baseUrl?: string, transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS): Promise<string> {
const matches = [...html.matchAll(IMG_SRC_REGEX)];
const replacements: { index: number; length: number; newBlock: string }[] = [];
for (const m of matches) {
const rawSrc = m[2];
const url = rawSrc.startsWith('//') ? `https:${rawSrc}` : rawSrc;
if (!url.startsWith('http')) continue;
const transformedPath = getCmsImageUrl(url, transformParams);
const href = transformedPath;
const newImg = m[0].replace(rawSrc, transformedPath);
const newBlock = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock });
}
if (replacements.length === 0) return html;
replacements.sort((a, b) => b.index - a.index);
let out = html;
for (const r of replacements) {
out = out.slice(0, r.index) + r.newBlock + out.slice(r.index + r.length);
}
return out;
}
export async function resolveContentImages(layout: RowContentLayoutLike, transformParamsOrOptions: RustyImageTransformParams | { transformParams?: RustyImageTransformParams; baseUrl?: string } = {}): Promise<void> {
const options = transformParamsOrOptions && 'baseUrl' in transformParamsOrOptions
? (transformParamsOrOptions as { transformParams?: RustyImageTransformParams; baseUrl?: string })
: { transformParams: transformParamsOrOptions as RustyImageTransformParams };
const transformParams = options.transformParams ?? {};
const rows = [layout.row1Content, layout.row2Content, layout.row3Content].filter((r): r is unknown[] => Array.isArray(r));
const galleryParams: RustyImageTransformParams = {
width: 1280,
fit: 'contain',
format: 'webp',
quality: 80,
...transformParams,
};
const orgLogoParams: RustyImageTransformParams = {
width: 256,
fit: 'contain',
format: 'webp',
quality: 85,
...transformParams,
};
const { marked } = await import('marked');
marked.setOptions({ gfm: true });
for (const content of rows) {
for (const item of content) {
if (typeof item !== 'object' || item === null) continue;
const block = item as {
_type?: string;
content?: string;
image?: unknown;
img?: unknown;
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
organisations?: Array<unknown>;
resolvedImageSrc?: string;
resolvedContent?: string;
};
if (block._type === 'markdown' && typeof block.content === 'string' && block.content.trim()) {
const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html, undefined, {
...MARKDOWN_IMAGE_PARAMS,
...transformParams,
});
continue;
}
if (block._type === 'image') {
const raw = block.image ?? block.img;
const url = absoluteUrlFromCmsImageField(raw);
if (url) {
block.resolvedImageSrc = getCmsImageUrl(url, transformParams);
}
continue;
}
if (block._type === 'image_gallery' && Array.isArray(block.images)) {
for (const img of block.images) {
if (!img || typeof img !== 'object') continue;
const url = absoluteUrlFromCmsImageField(img);
if (url) {
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(url, galleryParams);
}
}
continue;
}
if (block._type === 'organisations' && Array.isArray(block.organisations)) {
for (const org of block.organisations) {
if (!org || typeof org !== 'object') continue;
const o = org as { logo?: unknown; resolvedLogoSrc?: string };
const url = absoluteUrlFromCmsImageField(o.logo);
if (url) {
o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams);
}
}
continue;
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Server-seitiger Cache für /api/transform (Bilder vom CMS).
* Reduziert Aufrufe zum CMS; gleiche Parameter = gleiche Antwort (immutable).
*
* - In-Memory: LRU-artig, begrenzt auf TRANSFORM_CACHE_MAX_ENTRIES (Default 100).
* - Optional Datei-Cache: TRANSFORM_CACHE_DIR setzen (z. B. .cache/transform).
*/
const MAX_ENTRIES = Number(process.env.TRANSFORM_CACHE_MAX_ENTRIES) || 100;
const CACHE_DIR = process.env.TRANSFORM_CACHE_DIR?.trim() || '';
interface CachedImage {
buffer: ArrayBuffer;
contentType: string;
}
const memoryCache = new Map<string, CachedImage>();
/** Cache-Key aus Query-String (eindeutig pro URL + Parameter). */
function cacheKey(params: string): string {
return params;
}
/** In-Memory: ältesten Eintrag entfernen (Map behält Einfüge-Reihenfolge). */
function evictOldest(): void {
const first = memoryCache.keys().next();
if (first.value !== undefined) memoryCache.delete(first.value);
}
/** Prüft, ob Datei-Cache verfügbar ist (nur Server mit fs). */
async function getCacheDir(): Promise<string | null> {
if (!CACHE_DIR) return null;
try {
const { existsSync, mkdirSync } = await import('node:fs');
const { join } = await import('node:path');
const dir = join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
/** Sicheren Dateinamen aus Key erzeugen (Hash). */
function fileKey(key: string): string {
let h = 0;
for (let i = 0; i < key.length; i++)
h = (Math.imul(31, h) + key.charCodeAt(i)) | 0;
return Math.abs(h).toString(36) + key.length.toString(36);
}
/** Aus Datei-Cache lesen. */
async function getFromFile(key: string): Promise<CachedImage | null> {
const dir = await getCacheDir();
if (!dir) return null;
try {
const { readFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
const metaPath = join(dir, `${base}.meta`);
const dataPath = join(dir, `${base}.bin`);
const [ct, buf] = await Promise.all([
readFile(metaPath, 'utf8').catch(() => null),
readFile(dataPath).catch(() => null),
]);
if (ct && buf)
return {
buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
contentType: ct,
};
} catch {
// ignore
}
return null;
}
/** In Datei-Cache schreiben. */
async function setInFile(key: string, value: CachedImage): Promise<void> {
const dir = await getCacheDir();
if (!dir) return;
try {
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
await Promise.all([
writeFile(join(dir, `${base}.meta`), value.contentType),
writeFile(join(dir, `${base}.bin`), Buffer.from(value.buffer)),
]);
} catch {
// ignore
}
}
/**
* Transform-Ergebnis aus Cache holen (Memory zuerst, dann Datei).
*/
export async function getCachedTransform(params: string): Promise<CachedImage | null> {
const key = cacheKey(params);
const mem = memoryCache.get(key);
if (mem) return mem;
const file = await getFromFile(key);
if (file) {
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, file);
return file;
}
return null;
}
/**
* Transform-Ergebnis cachen (Memory + optional Datei).
*/
export async function setCachedTransform(
params: string,
buffer: ArrayBuffer,
contentType: string,
): Promise<void> {
const key = cacheKey(params);
const value: CachedImage = { buffer, contentType };
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, value);
await setInFile(key, value);
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Zentrale Übersetzungen (i18n-ähnlich).
* Quelle: CMS translation_bundle (API, hooks.server.ts setzt event.locals.translations).
* Wird in +layout.server.ts geladen und über TranslationProvider/Svelte-Context bereitgestellt.
*/
import { getContext } from "svelte";
export type Translations = Record<string, string>;
/** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */
export const T_CONTEXT_KEY = Symbol("translations.t");
/** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */
export type TranslationReplacements = Record<string, string | number>;
/** t(key) oder t(key, { placeholder: value }) mit optionalen Ersetzungen für {{placeholder}}. */
export type TFunction = (
key: string,
replacements?: TranslationReplacements,
) => string;
/**
* Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}.
*/
export function replacePlaceholders(
text: string,
replacements?: TranslationReplacements | null,
): string {
if (!replacements || typeof text !== "string") return text;
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
const val = replacements[name];
return val !== undefined && val !== null ? String(val) : `{{${name}}}`;
});
}
/**
* t-Funktion aus Svelte-Context holen.
* Zuverlässig innerhalb des TranslationProvider-Baums.
*/
export function useTranslate(): TFunction {
const fromContext = getContext<TFunction | undefined>(T_CONTEXT_KEY);
if (fromContext) return fromContext;
return (key: string, replacements?: TranslationReplacements): string => {
return replacePlaceholders(key, replacements);
};
}
/** Bekannte Übersetzungs-Keys nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */
const TRANSLATION_KEYS = [
"searchable_text_help",
"searchable_text_help_aria",
"searchable_text_placeholder",
"searchable_text_search_aria",
"searchable_text_clear_search",
"searchable_text_tag_all",
"searchable_text_no_results",
"searchable_text_count_all",
"searchable_text_count_filtered",
"searchable_text_copy_aria",
"searchable_text_copy_button",
"searchable_text_copied",
"searchable_text_untitled",
"footer_copyright",
"nav_start",
"nav_posts",
"nav_home",
"header_search_open",
"header_search_close",
"header_menu_open",
"header_menu_close",
"header_nav_aria",
"header_overlay_close",
"header_social_aria",
"site_name_fallback",
"pagination_prev",
"pagination_next",
"blog_tag_all",
"blog_filter_aria",
"blog_count",
"page_404_title",
"page_404_text",
"page_404_back",
"posts_title",
"posts_subtitle",
"posts_tag_title",
"posts_tag_subtitle",
"posts_cms_error",
"iframe_activate_aria",
"iframe_missing",
"image_gallery_swipe_aria",
"image_gallery_prev_aria",
"image_gallery_next_aria",
"image_gallery_position_aria",
"image_gallery_slide_aria",
"image_gallery_empty",
"youtube_missing",
"youtube_title_fallback",
"calendar_prev_month",
"calendar_next_month",
"calendar_weekday_mo",
"calendar_weekday_di",
"calendar_weekday_mi",
"calendar_weekday_do",
"calendar_weekday_fr",
"calendar_weekday_sa",
"calendar_weekday_so",
"calendar_more_info",
"calendar_no_events",
"calendar_time_suffix",
"calendar_select_day",
"calendar_next_events",
"calendar_show_more",
"calendar_show_less",
"post_overview_all",
"blog_search_label",
"blog_search_placeholder",
"blog_search_clear",
"blog_year_label",
"blog_year_all",
"blog_upcoming_events",
"blog_results_count",
"blog_results_for",
"blog_results_in_year",
"blog_filter_reset",
"blog_no_results",
"blog_rss_subscribe",
"post_map",
"post_map_activate",
"post_map_open_osm",
"post_comments",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
/** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */
export const T: { [K in TranslationKey]: K } = Object.fromEntries(
TRANSLATION_KEYS.map((k) => [k, k]),
) as { [K in TranslationKey]: K };
/**
* Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}.
* Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind).
*/
export function t(
translations: Translations | null | undefined,
key: string,
replacements?: TranslationReplacements,
): string {
if (!key) return key;
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Variant = 'default' | 'primary' | 'accent' | 'inactive' | 'date';
let {
label = '',
variant = 'default',
href = null as string | null,
active = false,
onclick = null as (() => void) | null,
} = $props();
const baseClass =
'inline-flex shrink-0 whitespace-nowrap rounded-full py-1 px-2.5 text-[0.6875rem] font-medium transition-all ring-1 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1';
const variantClasses = $derived(
{
default: 'bg-stein-0 text-stein-800 ring-stein-200 hover:bg-stein-50',
primary: 'bg-wald-500 text-white ring-wald-600/30 hover:bg-wald-600',
accent: 'bg-himmel-400 text-white ring-himmel-500/30 hover:bg-himmel-500',
inactive: 'bg-stein-100 text-stein-500 ring-stein-200',
date: 'bg-wald-600 text-white ring-wald-700/40',
}[variant]
);
const shadowClass = $derived(
href || onclick
? variant === 'date'
? 'shadow-[0_0_6px_rgba(0,0,0,0.25)]'
: 'shadow-[0_2px_6px_rgba(0,0,0,0.12)]'
: ''
);
const classes = $derived(
`${baseClass} ${variantClasses} ${shadowClass} ${active ? 'pointer-events-none ring-2' : ''}`
);
</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}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
/** @type {{ href?: string; label: string }[]} */
let { items = [] } = $props();
</script>
<nav aria-label="Breadcrumb" class="overflow-x-auto">
<ol class="list-none flex flex-nowrap items-center gap-x-1 text-xs py-4 pl-0 m-0!">
{#each items as item, i}
<li class="flex items-center gap-x-1.5 shrink-0 whitespace-nowrap">
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="inline-flex font-medium no-underline items-center gap-1.5 text-stein-500 hover:text-wald-600 transition-colors duration-150 rounded px-1 -mx-1 py-0.5 -my-0.5"
aria-label={i === 0 ? item.label : undefined}
>
{#if i === 0}
<Icon icon="mdi:home" class="size-4 shrink-0" aria-hidden="true" />
{:else}
<span>{item.label}</span>
{/if}
</a>
{:else}
<span
class="font-medium text-stein-800"
aria-current={i === items.length - 1 ? 'page' : undefined}
>
{item.label}
</span>
{/if}
{#if i < items.length - 1}
<span aria-hidden="true" class="shrink-0 text-stein-300">
<Icon icon="mdi:chevron-right" class="size-3.5" />
</span>
{/if}
</li>
{/each}
</ol>
</nav>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
} = $props();
const base =
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else}
<slot>{label}</slot>
{/if}
</button>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
name = '',
value = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="checkbox"
bind:checked
{disabled}
{name}
{value}
class="h-5 w-5 rounded border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/** Gleicher name für alle Radios in einer Gruppe; bind:group vom Parent hält den gewählten value. */
let {
name = '',
value = '',
/** Gebundener Wert der Gruppe entspricht dem value des ausgewählten Radios. */
group = $bindable(''),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="radio"
{name}
{value}
bind:group={group}
{disabled}
class="h-5 w-5 rounded-full border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
/** @type {{ value: string; label: string }[]} */
let {
options = [],
value = $bindable(''),
label = '',
placeholder = 'Bitte wählen',
disabled = false,
} = $props();
const selectId = $derived(`select-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<label
for={selectId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
</label>
{/if}
<div class="relative">
<select
id={selectId}
bind:value
{disabled}
class="h-12 w-full appearance-none rounded-lg border border-stein-300 bg-white px-4 py-3 pr-10 text-base text-stein-800 outline-none hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100 disabled:bg-stein-100 disabled:opacity-60"
>
<option value="" disabled>{placeholder}</option>
{#each options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span
class="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stein-500"
aria-hidden="true"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* @type {{ label: string; links: { href: string; label: string; active?: boolean }[] }[]}
*/
let { groups = [] } = $props();
</script>
<nav aria-label="Seitennavigation" class="w-60 bg-stein-50 p-4">
{#each groups as group, i}
<div class="{i > 0 ? 'mt-6' : ''}">
<p
class="mb-2 text-xs font-medium uppercase tracking-wider text-stein-500"
>
{group.label}
</p>
<ul class="space-y-0">
{#each group.links as link}
<li>
<a
href={link.href}
class="block rounded-lg px-3 py-2 text-sm {link.active
? 'bg-wald-50 font-medium text-wald-500'
: 'text-stein-700 hover:bg-wald-50'}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
{/each}
</nav>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let {
min = 0,
max = 100,
value = $bindable(50),
label = '',
disabled = false,
} = $props();
const inputId = $derived(`slider-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<div class="mb-1.5 flex items-center justify-between">
<label for={inputId} class="text-sm font-medium text-stein-700">
{label}
</label>
<span class="text-sm font-semibold text-stein-700">{value}</span>
</div>
{/if}
<input
type="range"
id={inputId}
{min}
{max}
bind:value
{disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
class="h-5 w-5 accent-wald-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-wald-500 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-stein-200"
/>
<div class="mt-1 flex justify-between text-xs text-stein-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/** @type {{ id: string; label: string }[]} */
let {
tabs = [],
selectedId = $bindable(''),
labelledBy = '',
} = $props();
$effect(() => {
if (tabs.length && !selectedId) selectedId = tabs[0].id;
});
function selectTab(id: string) {
selectedId = id;
}
function handleKeydown(e: KeyboardEvent) {
const idx = tabs.findIndex((t) => t.id === selectedId);
if (e.key === 'ArrowRight' && idx < tabs.length - 1) {
e.preventDefault();
selectedId = tabs[idx + 1].id;
} else if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault();
selectedId = tabs[idx - 1].id;
}
}
</script>
<div
role="tablist"
aria-label={labelledBy || 'Tabs'}
class="flex h-12 border-b border-stein-200"
onkeydown={handleKeydown}
>
{#each tabs as tab}
<button
type="button"
role="tab"
aria-selected={selectedId === tab.id}
aria-controls="panel-{tab.id}"
id="tab-{tab.id}"
class="border-b-2 px-4 text-sm font-medium transition-colors {selectedId === tab.id
? 'border-wald-500 text-wald-600 font-semibold'
: 'border-transparent text-stein-500 hover:text-stein-700'}"
tabindex={selectedId === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<input
{type}
{name}
{placeholder}
{required}
{disabled}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
rows = 4,
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<textarea
{name}
{placeholder}
{required}
{disabled}
{rows}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center gap-2 {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<span
class="relative inline-block h-6 w-11 shrink-0 rounded-full transition-all duration-200 {checked ? 'bg-wald-500' : 'bg-stein-300'}"
role="switch"
aria-checked={checked}
>
<input
type="checkbox"
class="sr-only"
bind:checked
{disabled}
onkeydown={(e) => e.key === ' ' && (checked = !checked)}
/>
<span
class="absolute top-0.5 inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 {checked ? 'left-[22px] translate-x-0.5' : 'left-0.5'}"
/>
</span>
{#if label}
<span class="text-base text-stein-700">{label}</span>
{/if}
</label>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
content,
placement: preferredPlacement = "top",
children,
}: { content: string; placement?: "top" | "bottom"; children?: Snippet } = $props();
const TOOLTIP_GAP = 8;
const TOOLTIP_MAX_WIDTH_PX = 352; // 22rem
let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left
function updatePosition() {
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top;
const spaceBelow = vh - rect.bottom;
placement =
preferredPlacement === "top"
? spaceAbove >= spaceBelow
? "top"
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max(
TOOLTIP_GAP,
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
}
function handleShow() {
updatePosition();
showTooltip = true;
}
function handleHide() {
showTooltip = false;
leftPx = null;
}
const placementClasses = $derived(
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2",
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
);
</script>
<div
bind:this={wrapperEl}
role="group"
class="relative inline-flex focus-within:outline-none"
onmouseenter={handleShow}
onmouseleave={handleHide}
onfocusin={handleShow}
onfocusout={handleHide}
>
{@render children?.()}
{#if showTooltip}
<div
role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-md shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle}
>
{content}
</div>
{/if}
</div>
+16
View File
@@ -0,0 +1,16 @@
/**
* Windwiderstand UI components
*/
export { default as TabBar } from './TabBar.svelte';
export { default as SidebarNav } from './SidebarNav.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as Button } from './Button.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as Radio } from './Radio.svelte';
export { default as Slider } from './Slider.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Tooltip } from './Tooltip.svelte';
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/stores';
import { useTranslate } from '$lib/translations';
const t = useTranslate();
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
</script>
<svelte:head>
<title>{status === 404 ? t('page_404_title') : `Fehler ${status}`}</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-4 py-12 text-center">
<h1 class="text-3xl font-bold text-zinc-900">{status}</h1>
{#if status === 404}
<p class="mt-2 text-zinc-600">{t('page_404_text')}</p>
{:else if message}
<p class="mt-2 text-zinc-600">{message}</p>
{/if}
<a
href="/"
class="mt-6 inline-block rounded bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800"
>
{t('page_404_back')}
</a>
</main>
+238
View File
@@ -0,0 +1,238 @@
import type { LayoutServerLoad } from './$types';
import {
getNavigationByKey,
NavigationKeys,
getPages,
getPosts,
getFooterBySlug,
getPageConfigBySlug,
} from '$lib/cms';
import type { NavLinkEntry } from '$lib/cms';
import { ensureTransformedImage } from '$lib/rusty-image';
import {
DEFAULT_SOCIAL_IMAGE_URL,
ROW_RESOLVE,
SITE_NAME,
SOCIAL_IMAGE_TRANSFORM,
} from '$lib/constants';
import { env } from '$env/dynamic/public';
interface NavLink {
href: string;
label: string;
}
interface SocialLink {
href: string;
icon: string;
label?: string;
}
/** 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 ?? '';
}
export const load: LayoutServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
// Header: Navigation laden
let headerLinks: NavLink[] = [];
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>();
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);
}
}
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 pageHref =
hrefSlug === 'home'
? '/'
: '/' +
hrefSlug
.split('/')
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
.join('/');
const href = postHref ?? pageHref;
headerLinks.push({ href, label });
}
} catch {
// CMS nicht erreichbar
}
// Social-Media-Links
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 = null;
try {
footerData = await getFooterBySlug('footer-main', {
locale: 'de',
resolve: ROW_RESOLVE,
});
} catch {
// CMS nicht erreichbar
}
// Logo und SEO-Templates aus page_config
let logoUrl: string | null = null;
let logoSvgHtml: string | null = null;
let pageConfig = null;
try {
pageConfig =
(await getPageConfigBySlug('page-config-default', {
locale: 'de',
resolve: 'logo',
})) ??
(await getPageConfigBySlug('default', { locale: 'de', resolve: 'logo' }));
const rawLogo = pageConfig?.logo;
const rawSrc =
typeof rawLogo === 'string'
? rawLogo
: (rawLogo as { src?: string } | undefined)?.src ?? null;
if (rawSrc) {
const cmsBase = (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
// Absolute URL aufbauen
const u = rawSrc.startsWith('//')
? `https:${rawSrc}`
: rawSrc.startsWith('/')
? `${cmsBase}${rawSrc}`
: rawSrc.startsWith('http')
? rawSrc
: `${cmsBase}/api/assets/${rawSrc}`;
const isSvg = u.split('?')[0].toLowerCase().endsWith('.svg');
if (isSvg) {
// SVGs können nicht transformiert werden — direkt verwenden und inline einbetten
logoUrl = u;
try {
const res = await fetch(u);
if (res.ok) {
const text = (await res.text()).trim();
if (text.includes('<svg')) logoSvgHtml = text;
}
} catch { /* Fallback: img mit logoUrl */ }
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: 'contain',
format: 'webp',
});
}
}
} catch {
/* Logo optional */
}
const siteName =
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, '');
// og-taugliches Social-Image (200x200) aus dem Logo vorbereiten — einmal pro Request.
let logoSocialImage: string | null = null;
try {
const source = logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
const transformed = await ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM);
logoSocialImage = transformed.startsWith('/')
? `${siteBaseUrl}${transformed}`
: transformed;
} catch {
logoSocialImage = null;
}
return {
headerLinks,
socialLinks,
footerData,
logoUrl,
logoSvgHtml,
logoSocialImage,
siteName,
siteBaseUrl,
translations,
pageConfig,
seoTitleTemplate: pageConfig?.seoTitle ?? null,
seoDescriptionTemplate: pageConfig?.seoDescription ?? null,
};
};
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import '../app.css';
import '$lib/iconify-offline';
import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte';
import HeaderOverlay from '$lib/components/HeaderOverlay.svelte';
import Footer from '$lib/components/Footer.svelte';
import TopBanner from '$lib/components/TopBanner.svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
import type { LayoutData } from './$types';
import { ensureTransformedImage } from '$lib/rusty-image';
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
// Per-page SEO data from $page.data
const pageData = $derived($page.data as {
seoTitle?: string;
seoDescription?: string;
socialImage?: string;
breadcrumbItems?: { href?: string; label: string }[];
topBanner?: { banner: unknown; resolvedImages: string[]; headline?: string; subheadline?: string } | null;
});
const baseUrl = $derived(data.siteBaseUrl ?? '');
const canonical = $derived(`${baseUrl}${$page.url.pathname}`);
function applyTemplate(tpl: string | null | undefined, value: string): string {
if (!tpl) return value;
return tpl.replace(/\$1/g, value);
}
const rawSeoTitle = $derived(pageData?.seoTitle ?? data.siteName);
const rawSeoDescription = $derived(pageData?.seoDescription ?? '');
const seoTitle = $derived(applyTemplate(data.seoTitleTemplate, rawSeoTitle));
const seoDescription = $derived(applyTemplate(data.seoDescriptionTemplate, rawSeoDescription));
const socialImageUrl = $derived.by(() => {
const raw = pageData?.socialImage ?? data.logoSocialImage ?? data.logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
if (!raw) return '';
if (raw.startsWith('//')) return `https:${raw}`;
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
if (raw.startsWith('/')) return `${baseUrl}${raw}`;
return raw;
});
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
const topBannerData = $derived(pageData?.topBanner ?? null);
const jsonLdScript = $derived(
`<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: data.siteName,
url: baseUrl,
inLanguage: 'de-DE',
})}<\/script>`,
);
const formbricksAppUrl = String(import.meta.env.PUBLIC_FORMBRICKS_APP_URL ?? '').replace(/\/$/, '');
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
const formbricksScript =
formbricksAppUrl && formbricksEnvId
? `<script>(function(){var appUrl=${JSON.stringify(formbricksAppUrl)};var environmentId=${JSON.stringify(formbricksEnvId)};if(!appUrl||!environmentId)return;var t=document.createElement('script');t.type='text/javascript';t.async=true;t.src=appUrl+'/js/formbricks.umd.cjs';var e=document.getElementsByTagName('script')[0];if(e&&e.parentNode)e.parentNode.insertBefore(t,e);setTimeout(function(){if(window.formbricks&&typeof window.formbricks.setup==='function'){window.formbricks.setup({environmentId:environmentId,appUrl:appUrl});}},500);})();<\/script>`
: '';
</script>
<svelte:head>
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicons/android-icon-192x192.png" />
<link rel="apple-touch-icon" sizes="57x57" href="/favicons/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/favicons/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/favicons/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/favicons/apple-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/favicons/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/favicons/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/favicons/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/favicons/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/favicons/ms-icon-144x144.png" />
<title>{seoTitle}</title>
{#if seoDescription}
<meta name="description" content={seoDescription} />
{/if}
<link rel="canonical" href={canonical} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={seoTitle} />
{#if seoDescription}
<meta property="og:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:width" content={String(SOCIAL_IMAGE_TRANSFORM.width)} />
<meta property="og:image:height" content={String(SOCIAL_IMAGE_TRANSFORM.height)} />
{/if}
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={data.siteName} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} />
<meta name="twitter:title" content={seoTitle} />
{#if seoDescription}
<meta name="twitter:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta name="twitter:image" content={socialImageUrl} />
{/if}
<!-- JSON-LD WebSite -->
{@html jsonLdScript}
{#if import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
<script
defer
src={import.meta.env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}
{#if import.meta.env.PUBLIC_ANALYTICS_PM86_SITE_ID}
<script
defer
src="https://analytics.pm86.de/api/script.js"
data-site-id={import.meta.env.PUBLIC_ANALYTICS_PM86_SITE_ID}
></script>
{/if}
{#if formbricksScript}
{@html formbricksScript}
{/if}
</svelte:head>
<TranslationProvider translations={data.translations}>
<div class="flex min-h-screen flex-col text-zinc-900">
<Header
links={data.headerLinks}
socialLinks={data.socialLinks}
logoUrl={data.logoUrl}
logoSvgHtml={data.logoSvgHtml}
translations={data.translations}
/>
<HeaderOverlay />
{#if topBannerData}
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
{/if}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{#if breadcrumbItems.length > 0}
<Breadcrumbs items={breadcrumbItems} />
{/if}
{@render children()}
</div>
</main>
<Footer footer={data.footerData} translations={data.translations} />
</div>
</TranslationProvider>
+84
View File
@@ -0,0 +1,84 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug, getPages, getHomePageSlugFromConfig } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from '$lib/blog-utils';
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
export const load: PageServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
try {
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
} catch {
// fallback
}
let page = null;
let pages: Awaited<ReturnType<typeof getPages>> = [];
try {
const [homePage, allPages] = await Promise.all([
getPageBySlug(homeSlug, { locale: 'de', resolve: PAGE_RESOLVE }),
getPages(),
]);
page = homePage;
pages = allPages ?? [];
if (page) {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
await resolveCalendarBlocks(page);
}
} catch {
// CMS nicht erreichbar
}
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
let topBanner: {
banner: import('$lib/cms').FullwidthBannerEntry | null;
resolvedImages: string[];
headline?: string;
subheadline?: string;
} | null = null;
if (page) {
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
? ((bannerRaw as { image?: unknown[] }).image ?? [])
: (bannerRaw as { image?: unknown }).image != null
? [(bannerRaw as { image?: unknown }).image]
: [];
let resolvedImages: string[] = [];
if (bannerImages.length > 0) {
resolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
fit: 'cover',
format: 'webp',
});
}
topBanner = {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
};
}
}
return {
page,
pages,
translations,
topBanner,
seoTitle: page
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
: siteName,
seoDescription: page ? (page.seoDescription ?? page.subheadline ?? '') : '',
};
};
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const page = $derived(data.page);
</script>
{#if page}
{#if !data.topBanner && (page.headline || page.subheadline)}
<div class="mb-6 pt-10 pageTitle">
{#if page.headline}<h1>{page.headline}</h1>{/if}
{#if page.subheadline}<h2>{page.subheadline}</h2>{/if}
</div>
{/if}
<article>
<ContentRows layout={page} translations={data.translations} />
</article>
{:else}
<div class="mb-6 pt-10 pageTitle">
<h1>Pages</h1>
</div>
<ul class="space-y-3 mt-4">
{#each data.pages as p}
<li>
<a
href={'/' + ((p.slug ?? p._slug ?? '').replace(/^\//, '').trim() || '').split('/').filter(Boolean).map(encodeURIComponent).join('/')}
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>
{#if p.headline}<span class="mt-1 block text-sm text-zinc-500">{p.headline}</span>{/if}
</a>
</li>
{/each}
</ul>
{/if}
+67
View File
@@ -0,0 +1,67 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from '$lib/blog-utils';
import { PAGE_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
const translations = locals.translations ?? {};
const page = await getPageBySlug(slug, {
locale: 'de',
resolve: PAGE_RESOLVE,
});
if (!page) {
error(404, 'Seite nicht gefunden');
}
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
await resolveCalendarBlocks(page);
// Resolve top banner if present
let topBannerResolvedImages: string[] = [];
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
? ((bannerRaw as { image?: unknown[] }).image ?? [])
: (bannerRaw as { image?: unknown }).image != null
? [(bannerRaw as { image?: unknown }).image]
: [];
if (bannerImages.length > 0) {
topBannerResolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
fit: 'cover',
format: 'webp',
});
}
}
return {
page,
translations,
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
seoDescription: page.seoDescription ?? page.subheadline ?? '',
breadcrumbItems: [
{ href: '/', label: 'Start' },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
],
topBanner: bannerRaw
? {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages: topBannerResolvedImages,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
}
: null,
};
};
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#if !data.topBanner && (data.page.headline || data.page.subheadline)}
<div class="mb-6 pt-10 pageTitle">
{#if data.page.headline}<h1>{data.page.headline}</h1>{/if}
{#if data.page.subheadline}<h2>{data.page.subheadline}</h2>{/if}
</div>
{/if}
<article>
<ContentRows layout={data.page} translations={data.translations} />
</article>
+65
View File
@@ -0,0 +1,65 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { invalidateAll, invalidateCollection } from '$lib/cms';
/**
* RustyCMS Webhook Receiver.
*
* Erwartet Payload mit `event` (z.B. "content.updated") und optional `collection` + `slug`.
* Auth via X-Revalidate-Token Header (shared secret aus REVALIDATE_TOKEN env var).
* Purged TTL-Cache: per-collection wenn collection bekannt, sonst komplett.
*/
type WebhookPayload = {
event?: string;
collection?: string;
slug?: string;
[k: string]: unknown;
};
export const POST: RequestHandler = async ({ request, url }) => {
const token = env.REVALIDATE_TOKEN;
if (!token) {
throw error(500, 'REVALIDATE_TOKEN not configured');
}
const provided =
request.headers.get('x-revalidate-token') ?? url.searchParams.get('token');
if (provided !== token) {
throw error(401, 'invalid token');
}
let payload: WebhookPayload = {};
try {
payload = (await request.json()) as WebhookPayload;
} catch {
// empty body = purge all
}
const event = payload.event ?? '';
const collection = payload.collection;
if (event.startsWith('schema.') || event === 'webhook.test') {
invalidateAll();
return json({ ok: true, purged: 'all', reason: event || 'no-event' });
}
if (event.startsWith('asset.')) {
// Assets referenziert überall → safe bet: purge all collections mit resolve
invalidateAll();
return json({ ok: true, purged: 'all', reason: event });
}
if (collection) {
const n = invalidateCollection(collection);
// Page/Post Listen cachen oft referenzen → auch "page" + "post" purgen wenn image/tag geändert
if (collection === 'img' || collection === 'image' || collection === 'tag') {
invalidateCollection('page');
invalidateCollection('post');
}
return json({ ok: true, purged: collection, entries: n, event });
}
invalidateAll();
return json({ ok: true, purged: 'all', reason: 'no collection in payload' });
};
+97
View File
@@ -0,0 +1,97 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getPages, getPosts } from '$lib/cms';
import {
filterHiddenPosts,
sortPostsByDate,
getPostImageUrl,
postHref,
} from '$lib/blog-utils';
import { ensureTransformedImage } from '$lib/rusty-image';
export type SearchHit = {
type: 'page' | 'post';
slug: string;
href: string;
title: string;
subtitle: string;
excerpt: string;
image: string | null;
created: string | null;
isEvent?: boolean;
eventDate?: string | null;
};
function pageHref(p: { slug?: string; _slug?: string }): string {
const raw = (p.slug ?? p._slug ?? '').replace(/^\//, '').trim();
if (!raw || raw === 'home') return '/';
return '/' + raw.split('/').filter(Boolean).map(encodeURIComponent).join('/');
}
export const GET: RequestHandler = async () => {
let pages: Awaited<ReturnType<typeof getPages>> = [];
let posts: Awaited<ReturnType<typeof getPosts>> = [];
try {
[pages, posts] = await Promise.all([
getPages(),
getPosts({ resolve: ['postImage'] }),
]);
} catch {
return json({ hits: [] satisfies SearchHit[] });
}
posts = filterHiddenPosts(sortPostsByDate(posts));
const postHits: SearchHit[] = await Promise.all(
posts.map(async (p) => {
const raw = getPostImageUrl(p.postImage);
let image: string | null = null;
if (raw) {
try {
image = await ensureTransformedImage(raw, {
width: 160,
height: 106,
fit: 'cover',
format: 'webp',
});
} catch {
/* optional */
}
}
return {
type: 'post',
slug: p.slug ?? p._slug ?? '',
href: postHref(p),
title: p.headline ?? p.linkName ?? p._slug ?? '',
subtitle: p.subheadline ?? '',
excerpt: p.excerpt ?? '',
image,
created: p.created ?? null,
isEvent: Boolean((p as { isEvent?: boolean }).isEvent),
eventDate: (p as { eventDate?: string }).eventDate ?? null,
};
}),
);
const pageHits: SearchHit[] = pages
.filter((p) => !(p as { hideFromSearch?: boolean }).hideFromSearch)
.map((p) => ({
type: 'page',
slug: p.slug ?? p._slug ?? '',
href: pageHref(p),
title:
(p as { headline?: string }).headline ??
(p as { title?: string }).title ??
p._slug ??
'',
subtitle: (p as { subheadline?: string }).subheadline ?? '',
excerpt: (p as { excerpt?: string }).excerpt ?? '',
image: null,
created: (p as { created?: string }).created ?? null,
}));
return json(
{ hits: [...pageHits, ...postHits] satisfies SearchHit[] },
{ headers: { 'cache-control': 'public, max-age=60, s-maxage=60' } },
);
};
+38
View File
@@ -0,0 +1,38 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { getCachedTransform, setCachedTransform } from '$lib/transform-cache';
const CACHE_HEADERS = {
'cache-control': 'public, max-age=31536000, immutable',
} as const;
export const GET: RequestHandler = async ({ url }) => {
const params = url.searchParams.toString();
const cached = await getCachedTransform(params);
if (cached) {
return new Response(cached.buffer, {
headers: {
'content-type': cached.contentType,
...CACHE_HEADERS,
},
});
}
const cmsUrl = (env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
const transformUrl = `${cmsUrl}/api/transform?${params}`;
const res = await fetch(transformUrl);
if (!res.ok) {
return new Response('Image transform failed', { status: res.status });
}
const buffer = await res.arrayBuffer();
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
await setCachedTransform(params, buffer, contentType);
return new Response(buffer, {
headers: {
'content-type': contentType,
...CACHE_HEADERS,
},
});
};
+6
View File
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
redirect(301, '/posts');
};
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
redirect(301, `/posts/page/${encodeURIComponent(params.page)}`);
};
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
redirect(301, `/posts/tag/${encodeURIComponent(params.tag)}`);
};
+106
View File
@@ -0,0 +1,106 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import {
buildCacheKey,
getCachedImage,
cacheImage,
type ImageTransformParams,
} from '$lib/image-cache.server';
const IMMUTABLE_HEADERS = {
'cache-control': 'public, max-age=31536000, immutable',
} as const;
function getCmsBaseUrl(): string {
return (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
}
/**
* Wenn src kein vollständiger URL ist, wird er als CMS-Asset-Dateiname behandelt
* und zu {CMS_URL}/api/assets/{src} aufgelöst.
* Absolute Pfade (z.B. /api/assets/logo/logo3.svg) werden direkt an die CMS-URL angehängt.
*/
function resolveSourceUrl(src: string): string {
if (src.startsWith('http://') || src.startsWith('https://')) return src;
if (src.startsWith('//')) return `https:${src}`;
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
}
export const GET: RequestHandler = async ({ url }) => {
const src = url.searchParams.get('src');
if (!src) {
return new Response('Missing "src" parameter', { status: 400 });
}
const params: ImageTransformParams = {};
const w = url.searchParams.get('w');
const h = url.searchParams.get('h');
const q = url.searchParams.get('q');
const format = url.searchParams.get('format');
const fit = url.searchParams.get('fit');
const ar = url.searchParams.get('ar');
if (w) {
const n = Number(w);
if (Number.isFinite(n)) params.w = n;
}
if (h) {
const n = Number(h);
if (Number.isFinite(n)) params.h = n;
}
if (q) {
const n = Number(q);
if (Number.isFinite(n)) params.q = n;
}
if (format) params.format = format;
if (fit) params.fit = fit;
if (ar) params.ar = ar;
const key = buildCacheKey(src, params);
const cached = await getCachedImage(key, params);
if (cached) {
return new Response(new Uint8Array(cached.buffer), {
headers: { 'content-type': cached.contentType, ...IMMUTABLE_HEADERS },
});
}
const sourceUrl = resolveSourceUrl(src);
const cmsBase = getCmsBaseUrl();
const transformParams = new URLSearchParams();
transformParams.set('url', sourceUrl);
if (params.w != null) transformParams.set('w', String(params.w));
if (params.h != null) transformParams.set('h', String(params.h));
if (params.q != null) transformParams.set('quality', String(params.q));
if (params.format) transformParams.set('format', params.format);
if (params.fit) transformParams.set('fit', params.fit);
if (params.ar) transformParams.set('ar', params.ar);
const transformUrl = `${cmsBase}/api/transform?${transformParams.toString()}`;
let res: Response;
try {
res = await fetch(transformUrl);
} catch (err) {
console.error('[cms-images] CMS fetch failed:', err);
return new Response('CMS not reachable', { status: 502 });
}
if (!res.ok) {
return new Response('Image transform failed', { status: res.status });
}
const buffer = await res.arrayBuffer();
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
const result = await cacheImage(key, buffer, contentType);
return new Response(new Uint8Array(result.buffer), {
headers: { 'content-type': result.contentType, ...IMMUTABLE_HEADERS },
});
};
+7
View File
@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
redirect(301, `/post/${encodeURIComponent(slug)}`);
};
+128
View File
@@ -0,0 +1,128 @@
import type { PageServerLoad } from './$types';
import { getPostBySlug, getPageConfigBySlug, getPageConfigs } from '$lib/cms';
import {
resolveContentImages,
ensureTransformedImage,
processMarkdownHtmlImages,
} from '$lib/rusty-image';
import {
resolvePostTagsInPost,
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
getPostImageUrl,
formatPostDate,
} from '$lib/blog-utils';
import { POST_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
marked.setOptions({ gfm: true });
export const load: PageServerLoad = async ({ params, locals, url }) => {
const { slug } = params;
const translations = locals.translations ?? {};
const post = await getPostBySlug(slug, {
locale: 'de',
resolve: POST_RESOLVE,
});
if (!post) {
error(404, 'Beitrag nicht gefunden');
}
await resolveContentImages(post);
const tagsMap = await resolvePostOverviewBlocks(post);
await resolveSearchableTextBlocks(post, tagsMap);
resolvePostTagsInPost(post, tagsMap);
const rawPostImageUrl = getPostImageUrl(post.postImage);
const postImageUrl = rawPostImageUrl
? await ensureTransformedImage(rawPostImageUrl, {
width: 150,
height: 200,
fit: 'cover',
})
: null;
const postDate = formatPostDate(post.created);
const postContent = (post as { content?: string }).content;
let contentHtml =
typeof postContent === 'string' && postContent.trim()
? (marked.parse(postContent) as string)
: '';
if (contentHtml) {
contentHtml = await processMarkdownHtmlImages(contentHtml);
}
const hasRowContent =
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
const eventDateLabel =
isEvent && eventDateRaw
? new Date(eventDateRaw).toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: null;
const eventLocation = isEvent
? ((post as { eventLocation?: { text?: string; lat?: number; lng?: number } }).eventLocation ?? null)
: null;
const hasCoords = !!(eventLocation?.lat && eventLocation?.lng);
const _lat = eventLocation?.lat ?? 0;
const _lng = eventLocation?.lng ?? 0;
const mapEmbedUrl = hasCoords
? `https://www.openstreetmap.org/export/embed.html?bbox=${_lng - 0.01},${_lat - 0.007},${_lng + 0.01},${_lat + 0.007}&layer=mapnik&marker=${_lat},${_lng}`
: null;
const mapLinkUrl = hasCoords
? `https://www.openstreetmap.org/?mlat=${eventLocation!.lat}&mlon=${eventLocation!.lng}#map=15/${eventLocation!.lat}/${eventLocation!.lng}`
: eventLocation?.text
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
: null;
const pageConfig =
(await getPageConfigBySlug('page-config-default', { locale: 'de' })) ??
(await getPageConfigs({ locale: 'de', per_page: 1 }))[0] ??
null;
const commentSlotRaw =
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? '';
const commentSlot = commentSlotRaw
.replace(/\{\{PAGE_ID\}\}/g, slug)
.replace(/\{\{PAGE_URL\}\}/g, url.href)
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
return {
post,
postImageUrl,
postDate,
contentHtml,
hasRowContent,
isEvent,
eventDateRaw,
eventDateLabel,
eventLocationText: eventLocation?.text ?? null,
mapEmbedUrl,
mapLinkUrl,
commentSlot: showCommentSection ? commentSlot : '',
translations,
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
socialImage: postImageUrl ?? undefined,
breadcrumbItems: [
{ href: '/', label: 'Start' },
{ href: '/posts', label: 'Beiträge' },
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
],
};
};
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import Tag from '$lib/components/Tag.svelte';
import Tags from '$lib/components/Tags.svelte';
import EventBadges from '$lib/components/EventBadges.svelte';
import EventMap from '$lib/components/EventMap.svelte';
import Accordion from '$lib/components/Accordion.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
{#if data.postImageUrl}
<meta property="og:image" content={data.postImageUrl} />
{/if}
</svelte:head>
{#if data.isEvent && (data.eventDateLabel || data.eventLocationText)}
<div class="mb-6">
<EventBadges
eventDate={data.eventDateRaw}
locationText={data.eventLocationText}
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
/>
</div>
{/if}
<div class="md:flex gap-2 items-end">
{#if data.postImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<img
src={data.postImageUrl}
alt=""
class="block object-cover"
width="150"
height="200"
style="aspect-ratio: 1.3333"
loading="eager"
/>
</div>
{/if}
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
{#if data.postDate}
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
<Tag label={data.postDate} variant="date" />
</div>
{/if}
<div class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden">
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
</div>
</div>
</div>
<article class="mt-8">
{#if data.contentHtml}
<div class="content markdown max-w-none prose prose-zinc">
{@html data.contentHtml}
</div>
{/if}
{#if data.hasRowContent}
<ContentRows layout={data.post} translations={data.translations} />
{/if}
</article>
{#if data.isEvent && (data.mapEmbedUrl || data.mapLinkUrl)}
<EventMap
embedUrl={data.mapEmbedUrl}
linkUrl={data.mapLinkUrl}
locationText={data.eventLocationText}
label={t(data.translations, T.post_map)}
translations={data.translations}
/>
{/if}
{#if data.commentSlot}
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
<div class="p-8 border border-zinc-200 rounded-lg">
{@html data.commentSlot}
</div>
</Accordion>
{/if}
+19
View File
@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ page: 1 });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} Aktuelles`,
seoDescription: 'Übersicht aller Beiträge und Meldungen.',
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ label: t(translations, T.nav_posts) },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
@@ -0,0 +1,20 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ page });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} Seite ${result.currentPage}`,
seoDescription: 'Übersicht aller Beiträge und Meldungen.',
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ label: t(translations, T.nav_posts) },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
+72
View File
@@ -0,0 +1,72 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { getPosts } from '$lib/cms';
import { filterHiddenPosts, sortPostsByDate, postHref } from '$lib/blog-utils';
import { SITE_NAME } from '$lib/constants';
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export const GET: RequestHandler = async ({ url }) => {
const site =
(env.PUBLIC_SITE_URL || url.origin).replace(/\/$/, '') || url.origin;
const siteName = env.PUBLIC_SITE_NAME || SITE_NAME;
let posts: Awaited<ReturnType<typeof getPosts>> = [];
try {
posts = filterHiddenPosts(sortPostsByDate(await getPosts()));
} catch {
posts = [];
}
const items = posts
.map((p) => {
const created =
(p as { created?: string; _created?: string }).created ??
(p as { _created?: string })._created ??
'';
const pubDate = created ? new Date(created) : null;
const pubDateStr =
pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate.toUTCString() : '';
const title = escapeXml(p.headline ?? p.slug ?? '');
const desc = escapeXml((p as { excerpt?: string }).excerpt ?? p.subheadline ?? '');
const link = `${site}${postHref(p)}`;
return [
'<item>',
`<title>${title}</title>`,
`<link>${link}</link>`,
`<guid isPermaLink="true">${link}</guid>`,
pubDateStr ? `<pubDate>${pubDateStr}</pubDate>` : '',
`<description>${desc}</description>`,
'</item>',
]
.filter(Boolean)
.join('');
})
.join('');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteName)} Beiträge</title>
<description>Aktuelles zur Windkraft, Gesundheit und Region.</description>
<link>${site}</link>
<language>de-de</language>
<atom:link href="${site}/posts/rss.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;
return new Response(body, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
});
};
@@ -0,0 +1,20 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ tagSlug: params.tag, page: 1 });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} ${result.tagName}`,
seoDescription: `Beiträge zum Thema ${result.tagName}.`,
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ href: '/posts', label: t(translations, T.nav_posts) },
{ label: result.tagName ?? '' },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_tag_subtitle, { tag: data.tagName ?? '' })}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ tagSlug: params.tag, page });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} ${result.tagName} Seite ${result.currentPage}`,
seoDescription: `Beiträge zum Thema ${result.tagName}.`,
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ href: '/posts', label: t(translations, T.nav_posts) },
{ href: `/posts/tag/${encodeURIComponent(params.tag)}`, label: result.tagName ?? '' },
],
};
};
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_tag_subtitle, { tag: data.tagName ?? '' })}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More