Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.cache/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
Generated
+2780
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
translations: Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Layout für Content-Blöcke (z. B. Markdown): Grid-Spalten (1–12) und Abstand.
|
||||||
|
* Entspricht component_layout im CMS (desktop/tablet/mobile = Breite in 12tel, spaceBottom = rem).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BlockLayout = {
|
||||||
|
/** Breite auf Desktop (1–12), z. B. "8" = 8/12. */
|
||||||
|
desktop?:
|
||||||
|
| "1"
|
||||||
|
| "2"
|
||||||
|
| "3"
|
||||||
|
| "4"
|
||||||
|
| "5"
|
||||||
|
| "6"
|
||||||
|
| "7"
|
||||||
|
| "8"
|
||||||
|
| "9"
|
||||||
|
| "10"
|
||||||
|
| "11"
|
||||||
|
| "12";
|
||||||
|
/** Breite auf Tablet (1–12). */
|
||||||
|
tablet?:
|
||||||
|
| "1"
|
||||||
|
| "2"
|
||||||
|
| "3"
|
||||||
|
| "4"
|
||||||
|
| "5"
|
||||||
|
| "6"
|
||||||
|
| "7"
|
||||||
|
| "8"
|
||||||
|
| "9"
|
||||||
|
| "10"
|
||||||
|
| "11"
|
||||||
|
| "12";
|
||||||
|
/** Breite auf Mobile (1–12), Default 12. */
|
||||||
|
mobile?:
|
||||||
|
| "1"
|
||||||
|
| "2"
|
||||||
|
| "3"
|
||||||
|
| "4"
|
||||||
|
| "5"
|
||||||
|
| "6"
|
||||||
|
| "7"
|
||||||
|
| "8"
|
||||||
|
| "9"
|
||||||
|
| "10"
|
||||||
|
| "11"
|
||||||
|
| "12";
|
||||||
|
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
|
||||||
|
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
|
||||||
|
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
|
||||||
|
breakout?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COL_SPAN: Record<string, string> = {
|
||||||
|
"1": "col-span-1",
|
||||||
|
"2": "col-span-2",
|
||||||
|
"3": "col-span-3",
|
||||||
|
"4": "col-span-4",
|
||||||
|
"5": "col-span-5",
|
||||||
|
"6": "col-span-6",
|
||||||
|
"7": "col-span-7",
|
||||||
|
"8": "col-span-8",
|
||||||
|
"9": "col-span-9",
|
||||||
|
"10": "col-span-10",
|
||||||
|
"11": "col-span-11",
|
||||||
|
"12": "col-span-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MD_COL_SPAN: Record<string, string> = {
|
||||||
|
"1": "md:col-span-1",
|
||||||
|
"2": "md:col-span-2",
|
||||||
|
"3": "md:col-span-3",
|
||||||
|
"4": "md:col-span-4",
|
||||||
|
"5": "md:col-span-5",
|
||||||
|
"6": "md:col-span-6",
|
||||||
|
"7": "md:col-span-7",
|
||||||
|
"8": "md:col-span-8",
|
||||||
|
"9": "md:col-span-9",
|
||||||
|
"10": "md:col-span-10",
|
||||||
|
"11": "md:col-span-11",
|
||||||
|
"12": "md:col-span-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LG_COL_SPAN: Record<string, string> = {
|
||||||
|
"1": "lg:col-span-1",
|
||||||
|
"2": "lg:col-span-2",
|
||||||
|
"3": "lg:col-span-3",
|
||||||
|
"4": "lg:col-span-4",
|
||||||
|
"5": "lg:col-span-5",
|
||||||
|
"6": "lg:col-span-6",
|
||||||
|
"7": "lg:col-span-7",
|
||||||
|
"8": "lg:col-span-8",
|
||||||
|
"9": "lg:col-span-9",
|
||||||
|
"10": "lg:col-span-10",
|
||||||
|
"11": "lg:col-span-11",
|
||||||
|
"12": "lg:col-span-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPACE_BOTTOM: Record<number, string> = {
|
||||||
|
0: "mb-0",
|
||||||
|
0.5: "mb-2", // 0.5rem
|
||||||
|
1: "mb-4", // 1rem
|
||||||
|
1.5: "mb-6", // 1.5rem
|
||||||
|
2: "mb-8", // 2rem
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
|
||||||
|
* Parent muss grid grid-cols-12 haben.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Gibt Tailwind-Klassen für den Block-Inhalt zurück.
|
||||||
|
* Bei breakout: true nur w-full + Abstand (Spalten/Wrapper kommen von außen).
|
||||||
|
*/
|
||||||
|
export function getBlockLayoutClasses(
|
||||||
|
layout: BlockLayout | undefined | null,
|
||||||
|
): string {
|
||||||
|
if (!layout || typeof layout !== "object") {
|
||||||
|
return "col-span-12 mb-4";
|
||||||
|
}
|
||||||
|
const breakout = layout.breakout === true;
|
||||||
|
const sb =
|
||||||
|
typeof layout.spaceBottom === "number"
|
||||||
|
? layout.spaceBottom
|
||||||
|
: Number(layout.spaceBottom);
|
||||||
|
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||||
|
|
||||||
|
if (breakout) {
|
||||||
|
return "w-full";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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 1–100 (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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatPostDate } from "$lib/blog-utils";
|
||||||
|
import Tag from "./Tag.svelte";
|
||||||
|
import Tags from "./Tags.svelte";
|
||||||
|
|
||||||
|
type TagItem = string | { name?: string; _slug?: string };
|
||||||
|
type TagVariant = "white" | "green" | "blue";
|
||||||
|
|
||||||
|
let {
|
||||||
|
date = null,
|
||||||
|
tags = [],
|
||||||
|
tagVariant = "white",
|
||||||
|
tagBase = null,
|
||||||
|
}: {
|
||||||
|
date?: string | null;
|
||||||
|
tags?: TagItem[];
|
||||||
|
tagVariant?: TagVariant;
|
||||||
|
tagBase?: string | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-1 min-w-0 flex flex-nowrap gap-1 items-center overflow-x-auto overflow-y-hidden p-1 pb-4">
|
||||||
|
{#if date}
|
||||||
|
<Tag label={formatPostDate(date)} variant="date" />
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-nowrap gap-1 items-center shrink-0">
|
||||||
|
<Tags tags={tags} variant={tagVariant} tagBase={tagBase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionHtml = $derived(
|
||||||
|
block.description && typeof block.description === "string"
|
||||||
|
? (marked.parse(block.description) as string)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="searchable-text flex flex-col gap-0 border border-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>
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Iconify Offline: MDI-Collection für Server und Client registrieren.
|
||||||
|
* Muss an zwei Stellen importiert werden (Side-Effect-Import), damit
|
||||||
|
* <Icon icon="mdi:…" /> überall gleich rendert (kein Hydration-Mismatch):
|
||||||
|
* 1. Layout.astro (--- Block) → läuft beim SSR
|
||||||
|
* 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser
|
||||||
|
* Danach können alle Svelte-Komponenten Icon ohne API nutzen.
|
||||||
|
*/
|
||||||
|
import { addCollection } from "@iconify/svelte";
|
||||||
|
import { icons as mdiIcons } from "@iconify-json/mdi";
|
||||||
|
|
||||||
|
addCollection(mdiIcons);
|
||||||
@@ -0,0 +1,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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
/** true = Backdrop über Content anzeigen (Menü oder Suche offen). */
|
||||||
|
export const overlayOpen = writable(false);
|
||||||
|
|
||||||
|
/** Callback zum Schließen (setzt im Header menuOpen/searchOpen auf false). */
|
||||||
|
export const overlayClose = writable<(() => void) | null>(null);
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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 ?? '') : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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' });
|
||||||
|
};
|
||||||
@@ -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' } },
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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)}`);
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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)}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getPostBySlug, getPageConfigBySlug, getPageConfigs } from '$lib/cms';
|
||||||
|
import {
|
||||||
|
resolveContentImages,
|
||||||
|
ensureTransformedImage,
|
||||||
|
processMarkdownHtmlImages,
|
||||||
|
} from '$lib/rusty-image';
|
||||||
|
import {
|
||||||
|
resolvePostTagsInPost,
|
||||||
|
resolvePostOverviewBlocks,
|
||||||
|
resolveSearchableTextBlocks,
|
||||||
|
getPostImageUrl,
|
||||||
|
formatPostDate,
|
||||||
|
} from '$lib/blog-utils';
|
||||||
|
import { POST_RESOLVE } from '$lib/constants';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true });
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||||
|
const { slug } = params;
|
||||||
|
const translations = locals.translations ?? {};
|
||||||
|
|
||||||
|
const post = await getPostBySlug(slug, {
|
||||||
|
locale: 'de',
|
||||||
|
resolve: POST_RESOLVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
error(404, 'Beitrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
await resolveContentImages(post);
|
||||||
|
const tagsMap = await resolvePostOverviewBlocks(post);
|
||||||
|
await resolveSearchableTextBlocks(post, tagsMap);
|
||||||
|
resolvePostTagsInPost(post, tagsMap);
|
||||||
|
|
||||||
|
const rawPostImageUrl = getPostImageUrl(post.postImage);
|
||||||
|
const postImageUrl = rawPostImageUrl
|
||||||
|
? await ensureTransformedImage(rawPostImageUrl, {
|
||||||
|
width: 150,
|
||||||
|
height: 200,
|
||||||
|
fit: 'cover',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const postDate = formatPostDate(post.created);
|
||||||
|
|
||||||
|
const postContent = (post as { content?: string }).content;
|
||||||
|
let contentHtml =
|
||||||
|
typeof postContent === 'string' && postContent.trim()
|
||||||
|
? (marked.parse(postContent) as string)
|
||||||
|
: '';
|
||||||
|
if (contentHtml) {
|
||||||
|
contentHtml = await processMarkdownHtmlImages(contentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRowContent =
|
||||||
|
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
|
||||||
|
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
|
||||||
|
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
|
||||||
|
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
|
||||||
|
const eventDateLabel =
|
||||||
|
isEvent && eventDateRaw
|
||||||
|
? new Date(eventDateRaw).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const eventLocation = isEvent
|
||||||
|
? ((post as { eventLocation?: { text?: string; lat?: number; lng?: number } }).eventLocation ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hasCoords = !!(eventLocation?.lat && eventLocation?.lng);
|
||||||
|
const _lat = eventLocation?.lat ?? 0;
|
||||||
|
const _lng = eventLocation?.lng ?? 0;
|
||||||
|
const mapEmbedUrl = hasCoords
|
||||||
|
? `https://www.openstreetmap.org/export/embed.html?bbox=${_lng - 0.01},${_lat - 0.007},${_lng + 0.01},${_lat + 0.007}&layer=mapnik&marker=${_lat},${_lng}`
|
||||||
|
: null;
|
||||||
|
const mapLinkUrl = hasCoords
|
||||||
|
? `https://www.openstreetmap.org/?mlat=${eventLocation!.lat}&mlon=${eventLocation!.lng}#map=15/${eventLocation!.lat}/${eventLocation!.lng}`
|
||||||
|
: eventLocation?.text
|
||||||
|
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const pageConfig =
|
||||||
|
(await getPageConfigBySlug('page-config-default', { locale: 'de' })) ??
|
||||||
|
(await getPageConfigs({ locale: 'de', per_page: 1 }))[0] ??
|
||||||
|
null;
|
||||||
|
const commentSlotRaw =
|
||||||
|
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? '';
|
||||||
|
const commentSlot = commentSlotRaw
|
||||||
|
.replace(/\{\{PAGE_ID\}\}/g, slug)
|
||||||
|
.replace(/\{\{PAGE_URL\}\}/g, url.href)
|
||||||
|
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
|
||||||
|
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
post,
|
||||||
|
postImageUrl,
|
||||||
|
postDate,
|
||||||
|
contentHtml,
|
||||||
|
hasRowContent,
|
||||||
|
isEvent,
|
||||||
|
eventDateRaw,
|
||||||
|
eventDateLabel,
|
||||||
|
eventLocationText: eventLocation?.text ?? null,
|
||||||
|
mapEmbedUrl,
|
||||||
|
mapLinkUrl,
|
||||||
|
commentSlot: showCommentSection ? commentSlot : '',
|
||||||
|
translations,
|
||||||
|
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
|
||||||
|
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
|
||||||
|
socialImage: postImageUrl ?? undefined,
|
||||||
|
breadcrumbItems: [
|
||||||
|
{ href: '/', label: 'Start' },
|
||||||
|
{ href: '/posts', label: 'Beiträge' },
|
||||||
|
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ContentRows from '$lib/components/ContentRows.svelte';
|
||||||
|
import Tag from '$lib/components/Tag.svelte';
|
||||||
|
import Tags from '$lib/components/Tags.svelte';
|
||||||
|
import EventBadges from '$lib/components/EventBadges.svelte';
|
||||||
|
import EventMap from '$lib/components/EventMap.svelte';
|
||||||
|
import Accordion from '$lib/components/Accordion.svelte';
|
||||||
|
import { t, T } from '$lib/translations';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.seoTitle}</title>
|
||||||
|
{#if data.seoDescription}
|
||||||
|
<meta name="description" content={data.seoDescription} />
|
||||||
|
{/if}
|
||||||
|
{#if data.postImageUrl}
|
||||||
|
<meta property="og:image" content={data.postImageUrl} />
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if data.isEvent && (data.eventDateLabel || data.eventLocationText)}
|
||||||
|
<div class="mb-6">
|
||||||
|
<EventBadges
|
||||||
|
eventDate={data.eventDateRaw}
|
||||||
|
locationText={data.eventLocationText}
|
||||||
|
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="md:flex gap-2 items-end">
|
||||||
|
{#if data.postImageUrl}
|
||||||
|
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
|
||||||
|
<img
|
||||||
|
src={data.postImageUrl}
|
||||||
|
alt=""
|
||||||
|
class="block object-cover"
|
||||||
|
width="150"
|
||||||
|
height="200"
|
||||||
|
style="aspect-ratio: 1.3333"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
|
||||||
|
{#if data.postDate}
|
||||||
|
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
|
||||||
|
<Tag label={data.postDate} variant="date" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden">
|
||||||
|
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="mt-8">
|
||||||
|
{#if data.contentHtml}
|
||||||
|
<div class="content markdown max-w-none prose prose-zinc">
|
||||||
|
{@html data.contentHtml}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.hasRowContent}
|
||||||
|
<ContentRows layout={data.post} translations={data.translations} />
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{#if data.isEvent && (data.mapEmbedUrl || data.mapLinkUrl)}
|
||||||
|
<EventMap
|
||||||
|
embedUrl={data.mapEmbedUrl}
|
||||||
|
linkUrl={data.mapLinkUrl}
|
||||||
|
locationText={data.eventLocationText}
|
||||||
|
label={t(data.translations, T.post_map)}
|
||||||
|
translations={data.translations}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.commentSlot}
|
||||||
|
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
|
||||||
|
<div class="p-8 border border-zinc-200 rounded-lg">
|
||||||
|
{@html data.commentSlot}
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
{/if}
|
||||||
@@ -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) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user