Compare commits
250 Commits
8f45094f64
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b87ad3c84 | |||
| 275f66fb8f | |||
| 28816bca87 | |||
| 0473b92201 | |||
| 57f4b226f9 | |||
| 5a1a49a97b | |||
| c8334dbd6c | |||
| 6ca8ae6720 | |||
| 3679978d6c | |||
| fe7bce2935 | |||
| 657f726edb | |||
| f77314dc49 | |||
| 8fd0112dcb | |||
| 3878a2ed26 | |||
| 7c7538a22c | |||
| b60da59c92 | |||
| 03e84c1f28 | |||
| 289cdb3951 | |||
| 91e66d8d4f | |||
| 4376c40369 | |||
| 7379d5750c | |||
| 84c41f80b1 | |||
| 121b593e4e | |||
| e96bfee317 | |||
| f86c302d49 | |||
| c60412bb53 | |||
| f8bbf59f90 | |||
| c12d626507 | |||
| e5566ff797 | |||
| 7a490b1ae1 | |||
| f7529d3b47 | |||
| 3908c117df | |||
| 5591bd86f0 | |||
| 85d6314ab4 | |||
| 7f67f59c8c | |||
| 25c1c5f2ed | |||
| 9115134852 | |||
| fe8afdb08e | |||
| 4d45f56719 | |||
| 2c61cc03b2 | |||
| 514e4425b5 | |||
| 4f408ee204 | |||
| 8c537a0ae7 | |||
| ca4dfad8be | |||
| 5b5fe8789e | |||
| cb66442aac | |||
| 7788e0370e | |||
| 552446b670 | |||
| d64754c238 | |||
| 52929122f5 | |||
| 81c067fa0c | |||
| d8a6508cde | |||
| 686af53be0 | |||
| af90483dc4 | |||
| 0ab2c58eb4 | |||
| e434ee3620 | |||
| a628e0e8d1 | |||
| a0e2015eeb | |||
| d484a7d90b | |||
| 8a0d3280f9 | |||
| 93fcf79f86 | |||
| ccf500fc62 | |||
| 0b3ddd86e3 | |||
| 905c2e4d36 | |||
| 6925ffee45 | |||
| fdc18ab5b8 | |||
| 781869b2b4 | |||
| eee25e8275 | |||
| 77ff44f6ef | |||
| ae9536db3a | |||
| b29dd587b8 | |||
| d9519a867e | |||
| 41597f87b5 | |||
| 1c63f68c0b | |||
| 08181129c4 | |||
| c225fb8be5 | |||
| 4f0ec5ce21 | |||
| f90116a923 | |||
| 21b555b61d | |||
| 155d9fb4b0 | |||
| 287a44ff6b | |||
| 6d1a084973 | |||
| ace3562111 | |||
| f0d9fecfb8 | |||
| d921b2758b | |||
| 415e772e57 | |||
| 894263e5c4 | |||
| aebe5f4854 | |||
| fc81d973a5 | |||
| c9ba88ab80 | |||
| 88b2df0c05 | |||
| 6c774c4913 | |||
| 982a7a3b73 | |||
| 4642b32271 | |||
| df1895bb91 | |||
| 9433705709 | |||
| 31f1c8586c | |||
| 3c4b847c23 | |||
| 80ee877ae0 | |||
| 363d4edb76 | |||
| 7bd62b75b0 | |||
| 1827d9772a | |||
| 5d2988fa1b | |||
| df4380409e | |||
| 6546f7d0a3 | |||
| f2379c0e28 | |||
| 77fee9b1c2 | |||
| 3f39b5b1a8 | |||
| bd67f0c568 | |||
| c58f418c1a | |||
| f05ee6b9d7 | |||
| 940b9beb7f | |||
| 0be73adc51 | |||
| 5d0a634359 | |||
| e80d9eef7e | |||
| a872a015d9 | |||
| 8194d8639d | |||
| 4e46e82770 | |||
| 2bae9b3e5d | |||
| 16bb8af12c | |||
| 6bc6781b3c | |||
| d45e691171 | |||
| e239c85961 | |||
| 28170a8549 | |||
| 2d1967ec38 | |||
| 8f260044e6 | |||
| 5f5bd06a78 | |||
| ff383b8b3b | |||
| cfb74096d1 | |||
| 96dd88247d | |||
| 6d0861bb0a | |||
| d781b3140e | |||
| b7bcdd76ee | |||
| 055f001174 | |||
| c329658992 | |||
| 6a1069f908 | |||
| f7a547874b | |||
| 81c82c33e6 | |||
| c00eb26689 | |||
| 0aa3898e5b | |||
| bcf1502093 | |||
| d03afe780e | |||
| 94a8feeb67 | |||
| 8575572183 | |||
| 9e05fbb2ba | |||
| 50d68a17ca | |||
| 8e43169b23 | |||
| a3f545d727 | |||
| 61d2093d0b | |||
| 12e0d4e3b8 | |||
| 9d58c92e94 | |||
| f08e3bb790 | |||
| cd362fb811 | |||
| 4a72142555 | |||
| 823adde1fa | |||
| f55f409158 | |||
| 250865893e | |||
| 07b6317b94 | |||
| 07f17e880e | |||
| baa4b9aae4 | |||
| 61e01c127f | |||
| 887a6bda3e | |||
| 774ac893ec | |||
| 1284be9c42 | |||
| 325f95487f | |||
| 1825b7d9fe | |||
| 4b9214dbaf | |||
| 44b991f7c1 | |||
| d7a84aff0d | |||
| fa50617d3c | |||
| cbf0e4aa51 | |||
| 35a34eb300 | |||
| 1f430f01a5 | |||
| 5b4d59175b | |||
| 4fc541acf9 | |||
| 19f3948f48 | |||
| 41dfbb2930 | |||
| a1dcedd855 | |||
| 3cb728d677 | |||
| 0b6174eb53 | |||
| b4581e4d4f | |||
| ad72c44d98 | |||
| f9b7057ba4 | |||
| cd67ff8dc8 | |||
| 0f6a58fed4 | |||
| 7d4f52ba02 | |||
| ca742345f3 | |||
| ee56a457d3 | |||
| 91a09fd3c4 | |||
| fece9c7cfc | |||
| 0cbf48f1fb | |||
| 0e28a7e334 | |||
| 6fef86fc6b | |||
| c66899fa9b | |||
| 067e847524 | |||
| 39d7d70c8b | |||
| cdc2a79950 | |||
| 81e419aa4d | |||
| 10fb2a3ef6 | |||
| 7ea23531e9 | |||
| 23faef550e | |||
| 6fc2c0a345 | |||
| 6fb2592eae | |||
| 7fa52bb629 | |||
| 4dd17b7ac4 | |||
| ec9930762d | |||
| ef163fb336 | |||
| 8db68e073c | |||
| b491e3767a | |||
| bf6ca475db | |||
| ce4c4570d6 | |||
| 892afc2d9f | |||
| fc6c59bc10 | |||
| a1c59b549d | |||
| 4790d571fd | |||
| 5a86ff701e | |||
| b633ed2d2b | |||
| 88514b065a | |||
| c4a09f03f4 | |||
| 44197429d6 | |||
| 3f62473c32 | |||
| 63a9721841 | |||
| 1f3fbe7fa7 | |||
| b7093b703a | |||
| 3ded972642 | |||
| 080e69dcac | |||
| 0d13e85639 | |||
| 9ebca1c237 | |||
| 2d85681252 | |||
| 96cd9e9c57 | |||
| f4e4abd978 | |||
| 8ddfd026bc | |||
| a2ca0db9ca | |||
| 615d9cdbd1 | |||
| d0dede92fe | |||
| 56084ac542 | |||
| 1e9ef44b52 | |||
| 5cf141fa4a | |||
| 616fb66c00 | |||
| 4db2fd8562 | |||
| dcce4fdea1 | |||
| 00d42ebb42 | |||
| 3bc7273e71 | |||
| 35f7aa8f90 | |||
| 3d343822e9 | |||
| 6f14e4f150 | |||
| 6b2b7245f4 | |||
| 3318fe9e17 | |||
| e4804ce673 | |||
| 8f791ebc54 |
@@ -32,10 +32,14 @@ jobs:
|
||||
cache: npm
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
- name: Generate icons subset
|
||||
run: npm run generate:icons
|
||||
- name: SvelteKit sync
|
||||
run: npx svelte-kit sync
|
||||
- name: Typecheck
|
||||
- name: Typecheck (svelte-check)
|
||||
run: npx svelte-check --threshold error
|
||||
- name: Typecheck (tsc)
|
||||
run: npx tsc --noEmit
|
||||
|
||||
deploy:
|
||||
needs: verify
|
||||
@@ -85,6 +89,13 @@ jobs:
|
||||
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_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
|
||||
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
|
||||
MATRIX_NOTIFY_URL=${{ secrets.MATRIX_NOTIFY_URL }}
|
||||
MATRIX_NOTIFY_TOKEN=${{ secrets.MATRIX_NOTIFY_TOKEN }}
|
||||
MATRIX_NOTIFY_ROOM=${{ secrets.MATRIX_NOTIFY_ROOM }}
|
||||
CMS_WEBHOOK_SECRET=${{ secrets.CMS_WEBHOOK_SECRET }}
|
||||
RUSTYCMS_TERMIN_API_KEY=${{ secrets.RUSTYCMS_TERMIN_API_KEY }}
|
||||
ENVEOF
|
||||
|
||||
- name: Pull and restart container
|
||||
|
||||
@@ -19,3 +19,6 @@ dist/
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Storybook
|
||||
storybook-static/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
|
||||
import strings from '$lib/components/blocks/_fixtures/translations.json';
|
||||
|
||||
// Storybook reicht die Story als `children`-Snippet rein. Wir setzen den
|
||||
// Translation-Context (echte deutsche Strings aus dem CMS-Bundle), damit
|
||||
// Blocks lesbare Texte statt roher Keys zeigen.
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<TranslationProvider translations={strings}>
|
||||
{@render children()}
|
||||
</TranslationProvider>
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { StorybookConfig } from '@storybook/sveltekit';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
|
||||
addons: [
|
||||
'@storybook/addon-svelte-csf',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
framework: '@storybook/sveltekit',
|
||||
// MSW-Service-Worker liegt in static/ (via `npx msw init static/`). Storybook
|
||||
// muss static/ ausliefern, damit /mockServiceWorker.js erreichbar ist.
|
||||
staticDirs: ['../static'],
|
||||
// Tailwind v4 läuft als Vite-Plugin. Das sveltekit-Framework bringt nur die
|
||||
// Svelte/SvelteKit-Integration mit ($app/$env/$lib-Mocks), nicht Tailwind —
|
||||
// darum hier explizit dazuhängen, sonst greifen keine Utility-Klassen/Tokens.
|
||||
viteFinal: async (viteConfig) => {
|
||||
viteConfig.plugins = viteConfig.plugins ?? [];
|
||||
viteConfig.plugins.push(tailwindcss());
|
||||
|
||||
// Same-origin-Routen, die im echten Frontend SvelteKit-Server-Endpoints
|
||||
// sind (in Storybook nicht existieren), auf die Live-Site proxien. Damit
|
||||
// rendern RustyImage-Bilder (/cms-images, /cms-files) und die Live-Daten-
|
||||
// Blocks (/api/strommix, /api/stellungnahme) mit echten Daten — ohne
|
||||
// Fixtures. Deterministische Offline-Mocks stattdessen via MSW (siehe
|
||||
// .storybook/README oder Kommentar unten).
|
||||
const PROXY_TARGET = 'https://windwiderstand.de';
|
||||
viteConfig.server = viteConfig.server ?? {};
|
||||
viteConfig.server.proxy = {
|
||||
...(viteConfig.server.proxy ?? {}),
|
||||
'/cms-images': { target: PROXY_TARGET, changeOrigin: true },
|
||||
'/cms-files': { target: PROXY_TARGET, changeOrigin: true },
|
||||
'/api/strommix': { target: PROXY_TARGET, changeOrigin: true },
|
||||
'/api/stellungnahme': { target: PROXY_TARGET, changeOrigin: true },
|
||||
};
|
||||
return viteConfig;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
SvelteKit liest $env/dynamic/public im Browser aus globalThis.__sveltekit_dev.env.
|
||||
Dieses Global setzt sonst nur der echte SvelteKit-Dev-Server. In Storybook hier
|
||||
vorab setzen, sonst crasht jede Komponente mit `env.PUBLIC_*` (Windkarte,
|
||||
Stellingnahme, …) mit "__sveltekit_dev is undefined". Läuft vor dem Story-Bundle.
|
||||
-->
|
||||
<script>
|
||||
window.__sveltekit_dev = window.__sveltekit_dev || { env: {} };
|
||||
window.__sveltekit_dev.env = Object.assign(
|
||||
{ PUBLIC_CMS_URL: 'https://cms.pm86.de' },
|
||||
window.__sveltekit_dev.env,
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Preview } from '@storybook/sveltekit';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import WithTranslations from './decorators/WithTranslations.svelte';
|
||||
// Design-System-Styles: Fonts, Tailwind, Farbtokens (wald/erde/…). Zieht die
|
||||
// echte app.css des Frontends → Stories sehen aus wie live.
|
||||
import '../src/app.css';
|
||||
|
||||
// MSW starten. `onUnhandledRequest: 'bypass'` → Requests ohne Handler laufen
|
||||
// normal durch (z.B. der Vite-Proxy für /cms-images). Nur explizit gemockte
|
||||
// Endpoints werden abgefangen → Blocks mit fetch entwickeln sich deterministisch
|
||||
// offline, per Story via `parameters.msw.handlers`.
|
||||
initialize({ onUnhandledRequest: 'bypass' });
|
||||
|
||||
// Default-Mock für den SvelteKit-page-Store/-State ($app/stores + $app/state).
|
||||
// Ohne das crasht jede Komponente, die $page.url liest (Header, PostActions, …).
|
||||
// Einzelne Stories können via parameters.sveltekit_experimental überschreiben.
|
||||
const mockPage = {
|
||||
url: new URL('http://localhost:6006/'),
|
||||
params: {},
|
||||
route: { id: null },
|
||||
status: 200,
|
||||
error: null,
|
||||
data: {},
|
||||
form: null,
|
||||
state: {},
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
loaders: [mswLoader],
|
||||
// Alle Stories in den Translation-Context wrappen → echte Texte statt Keys.
|
||||
decorators: [() => WithTranslations],
|
||||
parameters: {
|
||||
sveltekit_experimental: {
|
||||
stores: { page: mockPage },
|
||||
state: { page: mockPage },
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
// Frontend rendert auf hellem Grund; Storybook-Canvas dazu passend.
|
||||
backgrounds: {
|
||||
default: 'weiss',
|
||||
values: [
|
||||
{ name: 'weiss', value: '#ffffff' },
|
||||
{ name: 'stein', value: '#f5f5f4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -35,4 +35,7 @@ USER svelte
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3001/ > /dev/null || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- image-cache:/data/image-cache
|
||||
networks:
|
||||
- web
|
||||
stop_grace_period: 30s
|
||||
|
||||
volumes:
|
||||
image-cache:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Typo-Regelwerk – Windwiderstand
|
||||
|
||||
Verbindliche Typografie für die komplette Seite. Umsetzung in `src/app.css`.
|
||||
|
||||
## Schriften
|
||||
|
||||
- **Alles in Inter.** Lora nur für Zitate (kursiv). Sonst nie.
|
||||
- Erlaubte Gewichte: **300 / 400 / 500 / 600 / 700**. Nie 800/900.
|
||||
- Immer Satzschreibung (sentence case). Kein ALL-CAPS außer kleinen Labels.
|
||||
- Keine Ausrufezeichen. Keine Emoji.
|
||||
|
||||
## Skala
|
||||
|
||||
| Rolle | Größe | Gewicht | Line-height | Letter-spacing |
|
||||
|---|---|---|---|---|
|
||||
| Hero-H1 | clamp(38–66px) | 700 | 1.05 | -0.025em |
|
||||
| Leitstatement | clamp(26–38px) | 600 | 1.2 | -0.02em |
|
||||
| H2 Sektion | 34px | 600 | — | -0.02em |
|
||||
| H3 Karte/Sub | 22px | 600 | — | -0.01em |
|
||||
| Fließtext | 18px | 400 | 1.6 | — (bewusst groß & luftig) |
|
||||
| Klein/Meta | 13–14px | 400–500 | — | — |
|
||||
| Kicker-Label | 12px | 600 | — | 0.14em, UPPERCASE |
|
||||
|
||||
## Schriftfarbe
|
||||
|
||||
- Fließtext `#333735` (stein-700), Überschriften `#1f2221` (stein-800) — kein reines Schwarz
|
||||
- Links `#436e85` (himmel-500), hover `#375a6d`
|
||||
- Kicker / Nummer-Akzent `#2d7a45` (wald)
|
||||
|
||||
## Betonung
|
||||
|
||||
Über `<strong>` mit Gewicht **600** — nie kursiv, nie farbig.
|
||||
|
||||
## Rhythmus
|
||||
|
||||
- Nummerierte Sektionen (01–05) + Kicker-Label als Sektionsmarke
|
||||
- `text-wrap: balance` auf Headlines
|
||||
- Fließtext auf ~60–62ch begrenzen
|
||||
- Leitstatement zusätzlich auf ~24ch begrenzen
|
||||
- Großzügiger Weißraum, klare Hierarchie, keine Effekte
|
||||
|
||||
## Weiterlesen-Link
|
||||
|
||||
15px, Gewicht 600, Farbe himmel-500, dünne Unterlinie `#b8c9d4`, mit `mdi:arrow-right`.
|
||||
Generated
+5773
-25
File diff suppressed because it is too large
Load Diff
+37
-5
@@ -4,35 +4,67 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"predev": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node build",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"prepare": "svelte-kit sync || true"
|
||||
"generate:icons": "node scripts/generate-icons.mjs",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"typecheck": "svelte-kit sync && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "svelte-kit sync || true",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.114",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@storybook/addon-a11y": "^10.5.0",
|
||||
"@storybook/addon-docs": "^10.5.0",
|
||||
"@storybook/addon-svelte-csf": "^5.1.2",
|
||||
"@storybook/sveltekit": "^10.5.0",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.21.4",
|
||||
"@sveltejs/kit": "^2.65.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"msw": "^2.15.0",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"openapi-typescript": "^7.4.0",
|
||||
"storybook": "^10.5.0",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@turf/buffer": "^7.3.5",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"blurhash": "^2.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"marked": "^17.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.13.1",
|
||||
"satori": "^0.26.0",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tree-shake @iconify-json/mdi: scan src/ for `icon="mdi:..."` references and
|
||||
* emit a minimal IconifyJSON containing only those icons. Saves ~3 MB in the
|
||||
* client bundle vs. shipping the full 7000+ icon collection.
|
||||
*
|
||||
* Dynamic icons (from CMS data, conditional names) cannot be detected
|
||||
* statically — list them under EXTRA_ICONS below. Anything missing falls
|
||||
* through to the iconify API at runtime, which adds a network roundtrip but
|
||||
* does not break.
|
||||
*
|
||||
* Run via `npm run generate:icons`. Hooked into `prebuild`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
|
||||
/**
|
||||
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
|
||||
* and would otherwise miss the static scan. Add new ones here when editors
|
||||
* pick icons that appear blank — better than shipping the full collection.
|
||||
*/
|
||||
const EXTRA_ICONS = [
|
||||
// Brand/Logo-Icons ohne Lucide-Pendant (Editors picken sie als social.icon)
|
||||
'google',
|
||||
'whatsapp',
|
||||
'mastodon',
|
||||
'telegram',
|
||||
// Sprach-Logos (CodeBlock: dynamische language->icon-Map, kein literal icon="")
|
||||
'language-javascript',
|
||||
'language-python',
|
||||
'language-rust',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lucide-Icons aus CMS-Daten (Header social.icon, FilesBlock file types, …) +
|
||||
* dynamische Bindings, die der statische Scan nicht findet.
|
||||
*/
|
||||
const EXTRA_LUCIDE = [
|
||||
// Nav / Header social.icon (CMS)
|
||||
'calendar-1',
|
||||
'contact-round',
|
||||
'search',
|
||||
'x',
|
||||
'cloud',
|
||||
'mail',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'map',
|
||||
'video',
|
||||
'instagram',
|
||||
'twitter',
|
||||
'linkedin',
|
||||
'github',
|
||||
'phone',
|
||||
// PostActions / Links
|
||||
'share-2',
|
||||
'link',
|
||||
'external-link',
|
||||
'arrow-up-right',
|
||||
// Kontakt/Verzeichnis
|
||||
'id-card',
|
||||
'qr-code',
|
||||
'arrow-down-a-z',
|
||||
'users',
|
||||
// File-type icons (FilesBlock: dynamic f.iconName)
|
||||
'file',
|
||||
'file-text',
|
||||
'file-spreadsheet',
|
||||
'file-image',
|
||||
'file-video',
|
||||
'file-music',
|
||||
'file-down',
|
||||
'presentation',
|
||||
// Tag/flag/info/format fallbacks
|
||||
'tag',
|
||||
'flag',
|
||||
'info',
|
||||
'type',
|
||||
'image',
|
||||
// Windkarte / Map / Generator
|
||||
'fan',
|
||||
'mouse-pointer-click',
|
||||
'sliders-horizontal',
|
||||
'badge-check',
|
||||
];
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (/\.(svelte|ts|tsx|js|mjs)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanStatic(prefix) {
|
||||
const pattern = new RegExp(`icon="${prefix}:([a-z0-9-]+)"`, 'g');
|
||||
const found = new Set();
|
||||
for (const file of walk(SRC_DIR)) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
while ((m = pattern.exec(content)) !== null) {
|
||||
found.add(m[1]);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Build a tree-shaken subset for one Iconify collection. */
|
||||
function buildSubset({ prefix, pkg, outFile, extra }) {
|
||||
const iconsJson = path.join(ROOT, 'node_modules', '@iconify-json', pkg, 'icons.json');
|
||||
if (!fs.existsSync(iconsJson)) {
|
||||
console.error(`[generate-icons] missing ${iconsJson} — run \`npm install\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const collection = JSON.parse(fs.readFileSync(iconsJson, 'utf8'));
|
||||
const wanted = new Set([...scanStatic(prefix), ...extra]);
|
||||
|
||||
const icons = {};
|
||||
const aliases = {};
|
||||
const missing = [];
|
||||
|
||||
for (const name of wanted) {
|
||||
if (collection.icons[name]) {
|
||||
icons[name] = collection.icons[name];
|
||||
} else if (collection.aliases?.[name]) {
|
||||
aliases[name] = collection.aliases[name];
|
||||
const parent = collection.aliases[name].parent;
|
||||
if (parent && collection.icons[parent] && !icons[parent]) {
|
||||
icons[parent] = collection.icons[parent];
|
||||
}
|
||||
} else {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const subset = {
|
||||
prefix: collection.prefix,
|
||||
width: collection.width,
|
||||
height: collection.height,
|
||||
icons,
|
||||
...(Object.keys(aliases).length ? { aliases } : {}),
|
||||
};
|
||||
fs.writeFileSync(outFile, JSON.stringify(subset, null, 2) + '\n');
|
||||
|
||||
const sizeAfter = fs.statSync(outFile).size;
|
||||
console.log(
|
||||
`[generate-icons] ${prefix}: ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, outFile)} (${(sizeAfter / 1024).toFixed(1)} KB)`,
|
||||
);
|
||||
if (missing.length) {
|
||||
console.warn(`[generate-icons] ${prefix}: ${missing.length} not found: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
buildSubset({
|
||||
prefix: 'mdi',
|
||||
pkg: 'mdi',
|
||||
outFile: path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json'),
|
||||
extra: EXTRA_ICONS,
|
||||
});
|
||||
buildSubset({
|
||||
prefix: 'lucide',
|
||||
pkg: 'lucide',
|
||||
outFile: path.join(ROOT, 'src', 'lib', 'iconify-lucide-subset.generated.json'),
|
||||
extra: EXTRA_LUCIDE,
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,34 @@
|
||||
// Erzeugt PWA-Icons aus dem echten App-Logo (logo_v3.svg) auf hellem Grund.
|
||||
// Läuft manuell: `node scripts/generate-pwa-icons.mjs`
|
||||
import sharp from "sharp";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const staticDir = join(root, "static");
|
||||
const BG = "#f0f5f1"; // wald-50 (wie Header-Hintergrund)
|
||||
|
||||
const logoSvg = readFileSync(
|
||||
join(root, "..", "cms_content", "windwiderstand", "assets", "logo_v3.svg"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
async function makeIcon(size, logoRatio, out) {
|
||||
const logoPx = Math.round(size * logoRatio);
|
||||
const logo = await sharp(Buffer.from(logoSvg), { density: 384 })
|
||||
.resize(logoPx, logoPx, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const pad = Math.round((size - logoPx) / 2);
|
||||
await sharp({ create: { width: size, height: size, channels: 4, background: BG } })
|
||||
.composite([{ input: logo, top: pad, left: pad }])
|
||||
.png()
|
||||
.toFile(join(staticDir, out));
|
||||
console.log("wrote", out, `${size}px`);
|
||||
}
|
||||
|
||||
await makeIcon(192, 0.78, "icon-192.png");
|
||||
await makeIcon(512, 0.78, "icon-512.png");
|
||||
await makeIcon(512, 0.6, "icon-maskable-512.png"); // mehr Rand für Maskable-Safe-Zone
|
||||
await makeIcon(180, 0.78, "apple-touch-icon.png");
|
||||
@@ -0,0 +1,55 @@
|
||||
// Rewrite `mdi:NAME` -> `lucide:MAPPED` across frontend src + CMS content.
|
||||
// KEEP_MDI names are left untouched (brand/language logos with no lucide pendant).
|
||||
import { MAP, KEEP_MDI } from './icon-map.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WEB_ROOT = path.resolve(ROOT, '..');
|
||||
|
||||
const TARGETS = [
|
||||
{ dir: path.join(ROOT, 'src'), exts: /\.(svelte|ts|tsx|js|mjs)$/ },
|
||||
{ dir: path.join(WEB_ROOT, 'cms_content', 'windwiderstand'), exts: /\.json5$/ },
|
||||
];
|
||||
const SKIP_DIR = /(node_modules|\.history|\.search|_legacy|\.svelte-kit)/;
|
||||
|
||||
const stats = { files: 0, repl: 0, kept: {} };
|
||||
|
||||
function walk(dir, exts, acc) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.name.startsWith('.') && e.name !== '.') continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (SKIP_DIR.test(full)) continue;
|
||||
if (e.isDirectory()) walk(full, exts, acc);
|
||||
else if (exts.test(e.name)) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const RE = /mdi:([a-z0-9-]+)/g;
|
||||
for (const { dir, exts } of TARGETS) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of walk(dir, exts, [])) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let n = 0;
|
||||
const out = src.replace(RE, (m, name) => {
|
||||
if (KEEP_MDI.has(name)) {
|
||||
stats.kept[name] = (stats.kept[name] || 0) + 1;
|
||||
return m;
|
||||
}
|
||||
const l = MAP[name];
|
||||
if (!l) return m; // unknown -> leave, validator should have caught
|
||||
n++;
|
||||
return `lucide:${l}`;
|
||||
});
|
||||
if (n > 0) {
|
||||
fs.writeFileSync(file, out);
|
||||
stats.files++;
|
||||
stats.repl += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`rewrote ${stats.repl} refs in ${stats.files} files`);
|
||||
console.log('kept on mdi:', JSON.stringify(stats.kept));
|
||||
@@ -0,0 +1,104 @@
|
||||
// mdi -> lucide migration map. Names without a sensible lucide pendant
|
||||
// (brand/language logos) intentionally stay on mdi — listed in KEEP_MDI.
|
||||
export const KEEP_MDI = new Set([
|
||||
'google', 'whatsapp', 'mastodon', // brands missing in lucide
|
||||
'language-javascript', 'language-python', 'language-rust', // language logos
|
||||
]);
|
||||
|
||||
export const MAP = {
|
||||
'account-group-outline': 'users',
|
||||
'alert-circle': 'circle-alert',
|
||||
'alert-circle-outline': 'circle-alert',
|
||||
'arrow-right': 'arrow-right',
|
||||
'arrow-top-right': 'arrow-up-right',
|
||||
'calendar': 'calendar',
|
||||
'calendar-blank': 'calendar',
|
||||
'calendar-clock': 'calendar-clock',
|
||||
'calendar-export': 'calendar-arrow-up',
|
||||
'calendar-month-outline': 'calendar',
|
||||
'calendar-plus': 'calendar-plus',
|
||||
'calendar-search': 'calendar-search',
|
||||
'card-account-details-outline': 'id-card',
|
||||
'check': 'check',
|
||||
'check-circle': 'circle-check',
|
||||
'check-decagram': 'badge-check',
|
||||
'chevron-down': 'chevron-down',
|
||||
'chevron-left': 'chevron-left',
|
||||
'chevron-right': 'chevron-right',
|
||||
'chevron-up': 'chevron-up',
|
||||
'clock-alert-outline': 'alarm-clock',
|
||||
'clock-outline': 'clock',
|
||||
'clock-remove-outline': 'clock-fading',
|
||||
'close': 'x',
|
||||
'cloud': 'cloud',
|
||||
'cloud-off-outline': 'cloud-off',
|
||||
'code-json': 'braces',
|
||||
'comment-outline': 'message-circle',
|
||||
'comment-text-outline': 'message-square-text',
|
||||
'console': 'terminal',
|
||||
'content-copy': 'copy',
|
||||
'content-save-outline': 'save',
|
||||
'cursor-default-click': 'mouse-pointer-click',
|
||||
'cursor-default-click-outline': 'mouse-pointer-click',
|
||||
'database-outline': 'database',
|
||||
'domain': 'building-2',
|
||||
'download': 'download',
|
||||
'download-outline': 'download',
|
||||
'drag-vertical': 'grip-vertical',
|
||||
'email': 'mail',
|
||||
'email-outline': 'mail',
|
||||
'facebook': 'facebook',
|
||||
'file-document-edit-outline': 'file-pen',
|
||||
'file-document-outline': 'file-text',
|
||||
'file-download': 'file-down',
|
||||
'file-excel-box': 'file-spreadsheet',
|
||||
'file-image': 'file-image',
|
||||
'file-outline': 'file',
|
||||
'file-pdf-box': 'file-text',
|
||||
'file-powerpoint-box': 'presentation',
|
||||
'file-word-box': 'file-text',
|
||||
'flag-outline': 'flag',
|
||||
'folder-zip': 'folder-archive',
|
||||
'format-text-variant-outline': 'type',
|
||||
'home': 'house',
|
||||
'home-group': 'house',
|
||||
'home-outline': 'house',
|
||||
'image-outline': 'image',
|
||||
'information-outline': 'info',
|
||||
'link': 'link',
|
||||
'link-variant': 'link',
|
||||
'loading': 'loader-circle',
|
||||
'magnify': 'search',
|
||||
'magnify-plus': 'zoom-in',
|
||||
'map': 'map',
|
||||
'map-marker': 'map-pin',
|
||||
'map-marker-outline': 'map-pin',
|
||||
'map-outline': 'map',
|
||||
'menu': 'menu',
|
||||
'open-in-new': 'external-link',
|
||||
'pencil-outline': 'pencil',
|
||||
'phone': 'phone',
|
||||
'phone-outline': 'phone',
|
||||
'play': 'play',
|
||||
'printer': 'printer',
|
||||
'printer-outline': 'printer',
|
||||
'qrcode': 'qr-code',
|
||||
'refresh': 'refresh-cw',
|
||||
'reply': 'reply',
|
||||
'robot-outline': 'bot',
|
||||
'rss': 'rss',
|
||||
'ruler-square': 'ruler',
|
||||
'share-variant': 'share-2',
|
||||
'share-variant-outline': 'share-2',
|
||||
'shield-check-outline': 'shield-check',
|
||||
'sort-alphabetical-ascending': 'arrow-down-a-z',
|
||||
'transmission-tower': 'radio-tower',
|
||||
'trash-can-outline': 'trash-2',
|
||||
'tune': 'sliders-horizontal',
|
||||
'tune-variant': 'sliders-horizontal',
|
||||
'vector-square': 'square-dashed',
|
||||
'video': 'video',
|
||||
'wind-turbine': 'fan',
|
||||
'xml': 'code',
|
||||
'youtube': 'youtube',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MAP, KEEP_MDI } from './icon-map.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const lucide = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'node_modules/@iconify-json/lucide/icons.json'), 'utf8'),
|
||||
);
|
||||
const has = (n) => Boolean(lucide.icons[n] || (lucide.aliases && lucide.aliases[n]));
|
||||
|
||||
const all = fs
|
||||
.readFileSync(process.env.TMPDIR + '/mdi_all.txt', 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => s.replace('mdi:', ''));
|
||||
const cms = ['cloud', 'email', 'facebook', 'map', 'video', 'whatsapp', 'youtube'];
|
||||
const set = [...new Set([...all, ...cms])].sort();
|
||||
|
||||
const bad = [];
|
||||
const unmapped = [];
|
||||
for (const m of set) {
|
||||
if (KEEP_MDI.has(m)) continue;
|
||||
const l = MAP[m];
|
||||
if (l === undefined) {
|
||||
unmapped.push(m);
|
||||
continue;
|
||||
}
|
||||
if (has(l) === false) bad.push(`${m} -> lucide:${l}`);
|
||||
}
|
||||
console.log('total mdi:', set.length, '| keep-mdi:', [...KEEP_MDI].filter((k) => set.includes(k)).length);
|
||||
console.log('UNMAPPED:', unmapped.length ? unmapped.join(', ') : 'none');
|
||||
console.log('INVALID lucide names:', bad.length ? '\n ' + bad.join('\n ') : 'none');
|
||||
+247
-59
@@ -10,6 +10,37 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ==========================================================================
|
||||
Component Utilities
|
||||
========================================================================== */
|
||||
|
||||
@layer components {
|
||||
/* Shared card surface: border, background, shadow, radius */
|
||||
.card-surface {
|
||||
@apply rounded-lg border border-stein-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
/* Shared card hover: lift + shadow + wald border, used by all clickable card/tile/list-row components */
|
||||
.card-hover-lift {
|
||||
@apply transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md;
|
||||
}
|
||||
|
||||
/* Diagonal strikethrough for past calendar days */
|
||||
.day-past::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
background: linear-gradient(
|
||||
to top right,
|
||||
transparent calc(50% - 0.5px),
|
||||
rgba(0, 0, 0, 0.12) calc(50% - 0.5px),
|
||||
rgba(0, 0, 0, 0.12) calc(50% + 0.5px),
|
||||
transparent calc(50% + 0.5px)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Design Tokens – Windwiderstand (01-colors, 02-typography, 06-tokens.json)
|
||||
========================================================================== */
|
||||
@@ -64,6 +95,18 @@
|
||||
--color-fire-900: #981b18;
|
||||
--color-fire-950: #5d0f0b;
|
||||
|
||||
/* Palette: Gelb (Signal / Warnung / Aufmerksamkeit) */
|
||||
--color-gelb-50: #fefce8;
|
||||
--color-gelb-100: #fef9c3;
|
||||
--color-gelb-200: #fef08a;
|
||||
--color-gelb-300: #fde047;
|
||||
--color-gelb-400: #facc15;
|
||||
--color-gelb-500: #eab308;
|
||||
--color-gelb-600: #ca8a04;
|
||||
--color-gelb-700: #a16207;
|
||||
--color-gelb-800: #854d0e;
|
||||
--color-gelb-900: #713f12;
|
||||
|
||||
/* Palette: Stein (Neutrals) */
|
||||
--color-stein-0: #ffffff;
|
||||
--color-stein-50: #f7f8f7;
|
||||
@@ -94,6 +137,10 @@
|
||||
|
||||
/* Link (Himmel) – für @apply text-link */
|
||||
--color-link: var(--color-himmel-500);
|
||||
|
||||
/* Fonts – Tailwind-Utilities: font-sans (Inter, default), font-serif (Lora, Zitate) */
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-serif: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -135,7 +182,7 @@
|
||||
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.125rem; /* 18px – Body Large laut Design System */
|
||||
line-height: 1.556;
|
||||
line-height: 1.6;
|
||||
color: var(--color-font);
|
||||
}
|
||||
|
||||
@@ -152,33 +199,37 @@ main {
|
||||
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Lora nur Zitate.
|
||||
========================================================================== */
|
||||
|
||||
/* Heading-Defaults bewusst UNLAYERED: Tailwind-Preflight resettet h1–h6 in
|
||||
@layer base (font-size:inherit). Diese Rules müssen darüber liegen → unlayered.
|
||||
Folge: Utilities auf Headings brauchen `!` (siehe InfoCard/ContactFormBlock). */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--color-headings);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Mobile first, dann Tablet (768px), Desktop (1024px) */
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.125rem;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@@ -193,8 +244,8 @@ h4 {
|
||||
line-height: 2.625rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.375rem;
|
||||
@@ -212,12 +263,12 @@ h4 {
|
||||
line-height: 3rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.25rem;
|
||||
font-size: 2.125rem;
|
||||
line-height: 2.625rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,63 +325,188 @@ h4 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Content-Links: Himmel (Design System – Links) */
|
||||
main a:not(.no-underline) {
|
||||
/* Content-Links: Himmel (Design System – Links).
|
||||
* `:where(:not(.not-prose *))` schließt jeden Subtree mit `.not-prose`
|
||||
* aus — Card-Links etc. behalten ihre eigene Farbe + kein Underline. */
|
||||
main a:not(.no-underline):not([class*="btn"]):where(:not(.not-prose *)) {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main a:not(.no-underline):hover {
|
||||
main a:not(.no-underline):not([class*="btn"]):where(:not(.not-prose *)):hover {
|
||||
color: var(--color-himmel-600);
|
||||
}
|
||||
|
||||
/* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */
|
||||
/* Lange URLs/Links umbrechen ohne mitten im Wort zu trennen.
|
||||
* `overflow-wrap: anywhere` bricht nur wenn nötig (lange URL), während das
|
||||
* frühere `word-break: break-all` jedes Wort hackte — sichtbar bei
|
||||
* Card-Links wie PostCard, wo der ganze Card-Text in einem `<a>` liegt. */
|
||||
main a,
|
||||
.markdown a {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Weiterlesen-/„Mehr"-Links: ein Absatz, der NUR aus einem Link besteht, wird
|
||||
* als kompakter CTA-Link gerendert (kleiner, dünne Unterlinie, Pfeil).
|
||||
* Konvention: Link allein im Absatz = Weiterlesen-Link. `main …`-Prefix hebt
|
||||
* die Spezifität über die generische Content-Link-Regel. */
|
||||
main .markdown p:has(> a:only-child) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
main .markdown p > a:only-child {
|
||||
font-size: 15px; /* px, da :root=18px → rem-Basis verschoben */
|
||||
font-weight: 600;
|
||||
text-decoration-thickness: 1px;
|
||||
text-decoration-color: #b8c9d4;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
/* nbsp + Pfeil bleiben im Textfluss → durchgehende Unterlinie ohne Bruch. */
|
||||
main .markdown p > a:only-child::after {
|
||||
content: "\00a0→";
|
||||
font-weight: 400;
|
||||
}
|
||||
main .markdown p > a:only-child:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
main strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main ul:not(.not-prose) {
|
||||
main ul:not(.not-prose):where(:not(.not-prose *)) {
|
||||
margin-left: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
main ul:not(.not-prose) li {
|
||||
main ul:not(.not-prose):where(:not(.not-prose *)) li {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
main ol:not(.not-prose) {
|
||||
main ol:not(.not-prose):where(:not(.not-prose *)) {
|
||||
margin-left: 1.1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
main ol:not(.not-prose) li {
|
||||
main ol:not(.not-prose):where(:not(.not-prose *)) 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-*-Klassen entfernt (2026-07-12): Buttons laufen jetzt komplett über das
|
||||
Button-Atom (src/lib/ui/Button.svelte). Die --color-btn-* Tokens bleiben,
|
||||
weil das Atom `bg-btn-bg`/`text-btn-txt`/`bg-btn-hover-bg` nutzt. */
|
||||
|
||||
/* Filter-Chip — Basis für alle Filter-Chips (Kalender, Adressbuch, …).
|
||||
Aktiv/inaktiv-Farben werden kontextuell per Klasse gesetzt. */
|
||||
.chip {
|
||||
@apply inline-flex cursor-pointer items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
@apply bg-btn-hover-bg;
|
||||
/* Dekorativer Hintergrund mit zwei verschwommenen Verlaufs-Blobs (Wald → Erde).
|
||||
Nutzung: Container mit relativer Positionierung; Blobs sitzen in ::before/::after.
|
||||
Inhalt sollte z-Index >= 0 haben (Standard); Blobs liegen unter Stacking-Kontext. */
|
||||
.gradient-blob-bg {
|
||||
@apply relative isolate overflow-hidden;
|
||||
}
|
||||
|
||||
/* Content-Blöcke */
|
||||
.content p {
|
||||
.gradient-blob-bg::before,
|
||||
.gradient-blob-bg::after,
|
||||
.gradient-blob-bg-inner::before,
|
||||
.gradient-blob-bg-inner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: -10;
|
||||
width: 36rem;
|
||||
aspect-ratio: 577 / 310;
|
||||
transform: translate3d(0, -50%, 0);
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
var(--color-himmel-400) 0%,
|
||||
var(--color-himmel-200) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0.35;
|
||||
filter: blur(40px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gradient-blob-bg::before,
|
||||
.gradient-blob-bg-inner::before {
|
||||
left: max(-7rem, calc(50% - 52rem));
|
||||
}
|
||||
|
||||
.gradient-blob-bg::after,
|
||||
.gradient-blob-bg-inner::after {
|
||||
left: max(45rem, calc(50% + 8rem));
|
||||
}
|
||||
|
||||
/* Variante für Container die SELBST nicht clippen sollen (z.B. Header mit
|
||||
absolut positioniertem Dropdown): Blobs leben in einem inneren absoluten
|
||||
Layer, der overflow-hidden hat, der Parent bleibt overflow-visible. */
|
||||
.gradient-blob-bg-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Globaler Focus-Ring – wald-Akzent für Tastatur-Nutzer (a11y + Polish) */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-wald-500);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Lead-Paragraph: erstes <p> in einem Top-Level MarkdownBlock innerhalb
|
||||
<article> größer + leichter (Editorial). Bewusst auf article gescopt
|
||||
damit weder Cards (.markdown in Callouts) noch Footer-MarkdownBlocks
|
||||
mit Copyright-Text getroffen werden. */
|
||||
article [data-block-type="markdown"] .markdown > p:first-child {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.55;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
article [data-block-type="markdown"] .markdown > p:first-child {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content-Row-Trenner: subtiler wald-Verlauf zwischen content-rows (Forest-Identität) */
|
||||
.content-row + .content-row {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.content-row + .content-row::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-wald-200),
|
||||
transparent
|
||||
);
|
||||
margin: 0 auto 2rem;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
/* Content-Blöcke. `:where(:not(.not-prose *))` lässt jeden Subtree mit
|
||||
* .not-prose komplett ausserhalb dieser Margin-Defaults — Components, die
|
||||
* eigene Abstände setzen, müssen nur `not-prose` auf den Root packen. */
|
||||
.content p:where(:not(.not-prose *)) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.content h1,
|
||||
.content h2,
|
||||
.content h3,
|
||||
.content h4 {
|
||||
.content h1:where(:not(.not-prose *)),
|
||||
.content h2:where(:not(.not-prose *)),
|
||||
.content h3:where(:not(.not-prose *)),
|
||||
.content h4:where(:not(.not-prose *)) {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
@@ -372,7 +548,7 @@ main ol:not(.not-prose) li {
|
||||
|
||||
/* 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;
|
||||
@apply max-w-full w-full my-2 border border-stein-200 rounded-xs bg-stein-200 shadow-sm inline-block;
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
@@ -423,7 +599,7 @@ main code,
|
||||
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;
|
||||
@apply mt-3 mb-4 overflow-x-auto rounded-xs border border-stein-200 bg-stein-100 px-4 py-3 text-stein-800 leading-normal;
|
||||
}
|
||||
|
||||
main pre code,
|
||||
@@ -455,15 +631,12 @@ main pre code,
|
||||
}
|
||||
}
|
||||
|
||||
/* Zitate: Lora (Design System – nur für Zitate) */
|
||||
/* Zitate: Lora kursiv (Design System – nur für Zitate) */
|
||||
blockquote[data-block-type="quote"],
|
||||
blockquote[data-block-type="quote"] p,
|
||||
blockquote[data-block-type="quote"] cite {
|
||||
font-family: var(--font-secondary);
|
||||
}
|
||||
|
||||
.content-row + .content-row {
|
||||
margin-top: 1.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
@@ -483,26 +656,41 @@ footer a:hover,
|
||||
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;
|
||||
/* ── Print rules ────────────────────────────────────────────────────────────
|
||||
Goal: print only the article. `print:hidden` on chrome (header/footer/
|
||||
banner/breadcrumbs/comments) handles structural removal. The rules below
|
||||
set page margins, force black-on-white, expand links, and keep images
|
||||
inside the page break. */
|
||||
@media print {
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
}
|
||||
.calendar-block .calendar-column {
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
html,
|
||||
body {
|
||||
background: white \!important;
|
||||
color: black \!important;
|
||||
font-size: 11pt;
|
||||
}
|
||||
.calendar-block .calendar-events-wrapper {
|
||||
position: relative;
|
||||
/* Show the URL after each anchor inside the article so printed pages stay
|
||||
useful when the screen-only links are gone. */
|
||||
article a[href]:not([href^="#"])::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
word-break: break-all;
|
||||
}
|
||||
.calendar-block .calendar-events-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-top: none;
|
||||
border-left: 1px solid var(--color-stein-200);
|
||||
/* Don't break inside images, keep figures together with their captions. */
|
||||
img,
|
||||
figure {
|
||||
break-inside: avoid;
|
||||
max-width: 100% \!important;
|
||||
height: auto \!important;
|
||||
}
|
||||
/* Keep headings with the next paragraph. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
break-after: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -3,7 +3,15 @@
|
||||
<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" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#236335" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Windwiderstand" />
|
||||
<link rel="preconnect" href="https://cms.pm86.de">
|
||||
<link rel="preconnect" href="https://api.iconify.design">
|
||||
<link rel="preconnect" href="https://analytics.pm86.de">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
+108
-21
@@ -1,5 +1,10 @@
|
||||
import { getTranslationBundleBySlug } from '$lib/cms';
|
||||
import { getNormalizedTranslations } from '$lib/cms';
|
||||
import { isCmsAvailable } from '$lib/cms-health.server';
|
||||
import { maintenanceResponse } from '$lib/maintenance.server';
|
||||
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
||||
import { env as envPublic } from '$env/dynamic/public';
|
||||
import { logWarn } from '$lib/log.server';
|
||||
import { lookupRedirect } from '$lib/redirects.server';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
@@ -9,6 +14,8 @@ import type { Handle } from '@sveltejs/kit';
|
||||
const PAGE_CACHE_CONTROL =
|
||||
'public, max-age=0, s-maxage=30, stale-while-revalidate=300';
|
||||
|
||||
const ASSET_EXT_RE = /\.(ico|png|jpe?g|webp|gif|svg|css|js|mjs|map|woff2?|ttf|otf|txt|xml|json|pdf)$/i;
|
||||
|
||||
function isCacheablePath(pathname: string): boolean {
|
||||
if (pathname.startsWith('/api/')) return false;
|
||||
if (pathname.startsWith('/cms-images/')) return false;
|
||||
@@ -16,27 +23,87 @@ function isCacheablePath(pathname: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pfade auf denen wir vor dem Resolve einen CMS-Health-Check machen. */
|
||||
function shouldHealthCheck(pathname: string): boolean {
|
||||
if (pathname.startsWith('/api/')) return false;
|
||||
if (pathname.startsWith('/_app/')) return false;
|
||||
if (pathname.startsWith('/cms-images/')) return false;
|
||||
if (pathname.startsWith('/cms-files/')) return false;
|
||||
if (pathname === '/sitemap.xml' || pathname === '/robots.txt') return false;
|
||||
if (ASSET_EXT_RE.test(pathname)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boolean {
|
||||
if (pathname === '/maintenance' || pathname === '/maintenance/') return true;
|
||||
if (search.has('cms_down')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen.
|
||||
* Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom
|
||||
* Browser/CDN gecacht, daher kein Render-Aufruf nötig.
|
||||
*/
|
||||
function legacyRedirectTarget(pathname: string): string | null {
|
||||
if (pathname === '/blog' || pathname === '/blog/') return '/posts/';
|
||||
const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/);
|
||||
if (m1) return `/posts/page/${m1[1]}/`;
|
||||
const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/);
|
||||
if (m2) return `/posts/tag/${m2[1]}/`;
|
||||
const m3 = pathname.match(/^\/p\/([^/]+)\/?$/);
|
||||
if (m3) return `/post/${m3[1]}/`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
|
||||
// CMS abzuschalten. /maintenance oder ?cms_down=1.
|
||||
if (isMaintenanceSimulation(event.url.pathname, event.url.searchParams)) {
|
||||
return maintenanceResponse(200);
|
||||
}
|
||||
|
||||
// CMS-verwaltete Redirects (Admin UI → /admin/plugins/redirects).
|
||||
// Editor-Regeln gewinnen gegen hardcoded Legacy-Patterns; nur GET, sonst
|
||||
// brechen POST-Forms beim Pfadwechsel.
|
||||
if (event.request.method === 'GET') {
|
||||
const cmsRedirect = await lookupRedirect(event.url.pathname);
|
||||
if (cmsRedirect) {
|
||||
const search = event.url.search;
|
||||
const dest =
|
||||
cmsRedirect.to.startsWith('http') || !search
|
||||
? cmsRedirect.to
|
||||
: `${cmsRedirect.to}${search}`;
|
||||
return new Response(null, {
|
||||
status: cmsRedirect.status,
|
||||
headers: { location: dest },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
||||
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
||||
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
||||
if (redirectTo) {
|
||||
const dest = `${redirectTo}${event.url.search}`;
|
||||
return new Response(null, { status: 301, headers: { location: dest } });
|
||||
}
|
||||
|
||||
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
|
||||
// Cached, daher kein Roundtrip pro Request.
|
||||
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
|
||||
const ok = await isCmsAvailable();
|
||||
if (!ok) return maintenanceResponse(503);
|
||||
}
|
||||
|
||||
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 = await getNormalizedTranslations(
|
||||
TRANSLATION_BUNDLE_SLUG,
|
||||
{ locale: 'de' },
|
||||
);
|
||||
} catch (err) {
|
||||
event.locals.translations = {};
|
||||
logWarn('hooks.translations', err);
|
||||
}
|
||||
|
||||
const response = await resolve(event, {
|
||||
@@ -49,9 +116,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const isPreview = event.url.searchParams.has('preview');
|
||||
const isPreview =
|
||||
event.url.searchParams.has('preview') ||
|
||||
event.url.searchParams.has('preview_draft');
|
||||
const isPreviewDraft = event.url.searchParams.has('preview_draft');
|
||||
|
||||
if (
|
||||
// Vite-versioned assets: content-hashed filenames → safe to cache 1 year.
|
||||
if (event.url.pathname.startsWith('/_app/immutable/')) {
|
||||
response.headers.set('cache-control', 'public, max-age=31536000, immutable');
|
||||
} else if (
|
||||
event.request.method === 'GET' &&
|
||||
isCacheablePath(event.url.pathname) &&
|
||||
!isPreview &&
|
||||
@@ -73,5 +146,19 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Live-preview: allow the admin iframe to embed this response. The admin
|
||||
// origin is configured via PUBLIC_PREVIEW_PARENT_ORIGIN; when unset we fall
|
||||
// back to wildcard (dev only). Outside live-preview we leave existing
|
||||
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
|
||||
// stays intact.
|
||||
if (isPreviewDraft) {
|
||||
const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
||||
response.headers.set(
|
||||
'content-security-policy',
|
||||
`frame-ancestors 'self' ${parent || '*'}`,
|
||||
);
|
||||
response.headers.delete('x-frame-options');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Stub für `$env/dynamic/{public,private}`. Wird ausschließlich von Vitest
|
||||
* via Alias aufgelöst (siehe `vitest.config.ts`). Im Produktivbuild verwendet
|
||||
* SvelteKit das echte Modul.
|
||||
*
|
||||
* Tests können `process.env` direkt setzen — Proxy reicht alle Reads
|
||||
* unverändert durch.
|
||||
*/
|
||||
export const env = new Proxy({} as Record<string, string | undefined>, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
return process.env[prop];
|
||||
},
|
||||
has(_target, prop) {
|
||||
if (typeof prop !== 'string') return false;
|
||||
return prop in process.env;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { constantTimeEqual } from './auth-token.server';
|
||||
|
||||
describe('constantTimeEqual', () => {
|
||||
it('matches identical strings', () => {
|
||||
expect(constantTimeEqual('abc123', 'abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects different strings of same length', () => {
|
||||
expect(constantTimeEqual('abc123', 'abc124')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects different lengths', () => {
|
||||
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null/undefined input', () => {
|
||||
expect(constantTimeEqual(null, 'x')).toBe(false);
|
||||
expect(constantTimeEqual('x', undefined)).toBe(false);
|
||||
expect(constantTimeEqual(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(constantTimeEqual('', '')).toBe(true);
|
||||
expect(constantTimeEqual('', 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles unicode', () => {
|
||||
expect(constantTimeEqual('für', 'für')).toBe(true);
|
||||
expect(constantTimeEqual('für', 'fur')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Konstantzeitvergleich für Auth-Tokens (Webhook-Secrets, API-Keys).
|
||||
*
|
||||
* `a !== b` leakt timing-Information; `crypto.timingSafeEqual` vergleicht in
|
||||
* O(n) immer alle Bytes. Längen-Mismatch wird vorab geprüft, weil
|
||||
* `timingSafeEqual` bei ungleicher Länge wirft.
|
||||
*/
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function constantTimeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
const ab = Buffer.from(a, 'utf8');
|
||||
const bb = Buffer.from(b, 'utf8');
|
||||
if (ab.length !== bb.length) return false;
|
||||
return timingSafeEqual(ab, bb);
|
||||
}
|
||||
+44
-1
@@ -46,12 +46,36 @@ export type BlockLayout = {
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Start-Spalte auf Desktop (1–12) = Grid-Versatz (col-start). Optional. */
|
||||
desktopStart?:
|
||||
| "1" | "2" | "3" | "4" | "5" | "6"
|
||||
| "7" | "8" | "9" | "10" | "11" | "12";
|
||||
/** Start-Spalte auf Tablet (1–12). Optional. */
|
||||
tabletStart?:
|
||||
| "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;
|
||||
/** Abstand nach oben (rem): 0, 0.5, 1, 1.5, 2, 3, 4. */
|
||||
spaceTop?: 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4;
|
||||
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
|
||||
breakout?: boolean;
|
||||
};
|
||||
|
||||
const MD_COL_START: Record<string, string> = {
|
||||
"1": "md:col-start-1", "2": "md:col-start-2", "3": "md:col-start-3",
|
||||
"4": "md:col-start-4", "5": "md:col-start-5", "6": "md:col-start-6",
|
||||
"7": "md:col-start-7", "8": "md:col-start-8", "9": "md:col-start-9",
|
||||
"10": "md:col-start-10", "11": "md:col-start-11", "12": "md:col-start-12",
|
||||
};
|
||||
|
||||
const LG_COL_START: Record<string, string> = {
|
||||
"1": "lg:col-start-1", "2": "lg:col-start-2", "3": "lg:col-start-3",
|
||||
"4": "lg:col-start-4", "5": "lg:col-start-5", "6": "lg:col-start-6",
|
||||
"7": "lg:col-start-7", "8": "lg:col-start-8", "9": "lg:col-start-9",
|
||||
"10": "lg:col-start-10", "11": "lg:col-start-11", "12": "lg:col-start-12",
|
||||
};
|
||||
|
||||
const COL_SPAN: Record<string, string> = {
|
||||
"1": "col-span-1",
|
||||
"2": "col-span-2",
|
||||
@@ -105,6 +129,16 @@ const SPACE_BOTTOM: Record<number, string> = {
|
||||
2: "mb-8", // 2rem
|
||||
};
|
||||
|
||||
const SPACE_TOP: Record<number, string> = {
|
||||
0: "mt-0",
|
||||
0.5: "mt-2", // 0.5rem
|
||||
1: "mt-4", // 1rem
|
||||
1.5: "mt-6", // 1.5rem
|
||||
2: "mt-8", // 2rem
|
||||
3: "mt-12", // 3rem
|
||||
4: "mt-16", // 4rem
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
|
||||
* Parent muss grid grid-cols-12 haben.
|
||||
@@ -126,18 +160,27 @@ export function getBlockLayoutClasses(
|
||||
: Number(layout.spaceBottom);
|
||||
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||
|
||||
const st =
|
||||
typeof layout.spaceTop === "number" ? layout.spaceTop : Number(layout.spaceTop);
|
||||
const spaceTopClass = SPACE_TOP[st as keyof typeof SPACE_TOP] ?? "";
|
||||
|
||||
if (breakout) {
|
||||
return "w-full";
|
||||
return [spaceTopClass, "w-full"].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
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 tabletStart = layout.tabletStart != null ? String(layout.tabletStart) : undefined;
|
||||
const desktopStart = layout.desktopStart != null ? String(layout.desktopStart) : undefined;
|
||||
|
||||
const parts: string[] = [
|
||||
COL_SPAN[mobile] ?? "col-span-12",
|
||||
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
|
||||
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
|
||||
tabletStart ? (MD_COL_START[tabletStart] ?? "") : "",
|
||||
desktopStart ? (LG_COL_START[desktopStart] ?? "") : "",
|
||||
spaceTopClass,
|
||||
spaceClass,
|
||||
];
|
||||
return parts.filter(Boolean).join(" ");
|
||||
|
||||
+301
-5
@@ -45,6 +45,12 @@ export interface HeadlineBlockData {
|
||||
_slug?: string;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
text?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). Gesetzt → Headline rendert als Sektionskopf. */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
align?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
@@ -121,6 +127,12 @@ export interface ImageGalleryBlockData {
|
||||
description?: string;
|
||||
/** Optional: oberhalb des Galerie-Modals als Header. */
|
||||
title?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
|
||||
variant?: "standard" | "grid";
|
||||
images?: ImageGalleryImage[];
|
||||
@@ -144,6 +156,12 @@ export interface FilesBlockData {
|
||||
_type?: "files";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
description?: string;
|
||||
items?: Array<string | FilesBlockItem>;
|
||||
forceDownload?: boolean;
|
||||
@@ -168,6 +186,24 @@ export interface YoutubeVideoBlockData {
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Galerie aus YouTube-Video-Referenzen (_type: "youtube_video_gallery"). */
|
||||
export interface YoutubeVideoGalleryBlockData {
|
||||
_type?: "youtube_video_gallery";
|
||||
_slug?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
description?: string;
|
||||
variant?: "carousel" | "grid";
|
||||
videos?: YoutubeVideoBlockData[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Zitat (_type: "quote"). */
|
||||
export interface QuoteBlockData {
|
||||
_type?: "quote";
|
||||
@@ -204,6 +240,8 @@ export interface LinkListBlockData {
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
|
||||
/** Spaltenlayout: "auto" = mehrspaltig bei >4 Links, "single" = immer untereinander. */
|
||||
columns?: "auto" | "single";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
@@ -212,6 +250,12 @@ export interface PostOverviewBlockData {
|
||||
_type?: "post_overview";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** SectionHeader: kleines Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: optionaler Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst aus link-Collection: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
/** Einleitungstext (Markdown). */
|
||||
text?: string;
|
||||
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
|
||||
@@ -224,8 +268,8 @@ export interface PostOverviewBlockData {
|
||||
excludeTag?: string[];
|
||||
/** Max. Anzahl Einträge. */
|
||||
numberItems?: number;
|
||||
/** "list" | "cards". */
|
||||
design?: "list" | "cards";
|
||||
/** "list" | "cards" | "carousel" | "featured" (1. Post groß links, Rest kompakt rechts). */
|
||||
design?: "list" | "cards" | "carousel" | "featured";
|
||||
layout?: BlockLayout;
|
||||
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
|
||||
postsResolved?: unknown[];
|
||||
@@ -272,6 +316,12 @@ export interface SearchableTextBlockData {
|
||||
_slug?: string;
|
||||
/** Titel der Suchkomponente. */
|
||||
title?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
/** Einleitung (Markdown). */
|
||||
description?: string;
|
||||
/** Slugs oder aufgelöste text_fragment-Einträge. */
|
||||
@@ -301,6 +351,10 @@ export interface OrganisationEntry {
|
||||
description?: string;
|
||||
link?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
badge?: string | { label?: string; color?: string };
|
||||
whatsapp?: string;
|
||||
youtube?: string;
|
||||
location?: { text?: string; lat?: number; lng?: number };
|
||||
contacts?: (string | ContactEntry)[];
|
||||
}
|
||||
|
||||
/** Organisations-Block (_type: "organisations"). */
|
||||
@@ -308,9 +362,15 @@ export interface OrganisationsBlockData {
|
||||
_type?: "organisations";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
description?: string;
|
||||
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
|
||||
presentation?: "default" | "compact" | "carousel";
|
||||
presentation?: "default" | "compact" | "carousel" | "grid";
|
||||
addOrganisationTitle?: string;
|
||||
addOrganisationMarkdown?: string;
|
||||
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
|
||||
@@ -319,8 +379,125 @@ export interface OrganisationsBlockData {
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. */
|
||||
export type CalendarItemData = components["schemas"]["calendar_item"];
|
||||
export interface OrganisationsMapBlockData {
|
||||
_type?: "organisations_map";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
organisations?: (string | OrganisationEntry)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
export interface ContactEntry {
|
||||
_type?: "contact";
|
||||
_slug?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
organisations?: (string | { _slug?: string; name?: string })[];
|
||||
email?: string;
|
||||
phone?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ContactsBlockData {
|
||||
_type?: "contacts_block";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
contacts?: (string | ContactEntry)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
export interface AdressbuchBlockData {
|
||||
_type?: "adressbuch";
|
||||
_slug?: string;
|
||||
excludeContacts?: (string | ContactEntry)[];
|
||||
layout?: BlockLayout;
|
||||
/** Server-side injected — not in CMS schema */
|
||||
resolvedContacts?: ContactEntry[];
|
||||
}
|
||||
|
||||
/** Live-Strommix-Widget (_type: "live_strommix"). Editorial-Konfig + Platzierungs-Instanz.
|
||||
* Fetched live data from /api/strommix (aggregator) and renders status bucket
|
||||
* derived from `(wind_onshore + wind_offshore + solar) / load * 100`. */
|
||||
export interface LiveStrommixBlockData {
|
||||
_type?: "live_strommix";
|
||||
_slug?: string;
|
||||
/** `full` = card with argument + breakdown disclosure + history accordion.
|
||||
* `hero` = pct + status + sparkline + trend, no sub-cards or accordion.
|
||||
* `compact` = single horizontal strip for sidebar / homepage placement.
|
||||
* Default: `full`. */
|
||||
variant?: "full" | "hero" | "compact";
|
||||
intro_headline?: string;
|
||||
intro_text?: string;
|
||||
threshold_dunkelflaute?: number;
|
||||
threshold_niedrig?: number;
|
||||
threshold_mittel?: number;
|
||||
label_dunkelflaute?: string;
|
||||
label_niedrig?: string;
|
||||
label_mittel?: string;
|
||||
label_hoch?: string;
|
||||
argument_dunkelflaute?: string;
|
||||
argument_niedrig?: string;
|
||||
argument_mittel?: string;
|
||||
argument_hoch?: string;
|
||||
enable_france_comparison?: boolean;
|
||||
disclaimer?: string;
|
||||
/** Day-ahead price (€/MWh) below which `disclaimer` is rendered.
|
||||
* Default 10 — disclaimer about EEG-Vergütung at negative prices is
|
||||
* irrelevant at 130 €/MWh and gets hidden. Set very high (e.g. 9999)
|
||||
* to always show. */
|
||||
disclaimer_threshold_eur_mwh?: number;
|
||||
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
|
||||
explanation_page?: string | { _slug?: string; title?: string };
|
||||
// ---------------------------------------------------------------------
|
||||
// Stammdaten (BNetzA Marktstammdatenregister, manuell gepflegt). Treiben
|
||||
// den Live-vs-Installiert-Auslastungs-Bereich. Stand quartalsweise
|
||||
// aktualisieren — die Werte bewegen sich um 1–3 GW pro Quartal.
|
||||
// ---------------------------------------------------------------------
|
||||
installed_wind_onshore_gw?: number;
|
||||
installed_wind_offshore_gw?: number;
|
||||
installed_solar_gw?: number;
|
||||
/** Free-form Stand-Marker für die Stammdaten, z. B. "2025-Q4". */
|
||||
installed_as_of?: string;
|
||||
/** Optional: Hand-gepflegte EinsMan-Entschädigungssumme YTD (Mio €).
|
||||
* BNetzA liefert das nur quartalsweise per PDF. Wenn nicht gesetzt,
|
||||
* zeigt das Widget den Counter ohne Eurosumme. */
|
||||
einsman_costs_ytd_eur_mio?: number;
|
||||
einsman_costs_as_of?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time.
|
||||
* Lokale Erweiterungen sind im RustyCMS-Schema ergänzt und werden bis zur
|
||||
* OpenAPI-Regen hier inline mitgeführt. */
|
||||
export type CalendarItemData = components["schemas"]["calendar_item"] & {
|
||||
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
|
||||
terminEnde?: string;
|
||||
/** Optionaler Veranstaltungsort (string oder {text, lat, lng}). */
|
||||
location?: string | { text?: string; lat?: number; lng?: number };
|
||||
/** Optionale Tags zur Filterung. */
|
||||
tags?: string[];
|
||||
/** Optionales Veranstaltungsbild (aufgelöstes image-Feld oder Slug). */
|
||||
image?:
|
||||
| string
|
||||
| {
|
||||
_type?: "img";
|
||||
_slug?: string;
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
};
|
||||
/** Optionaler Dateianhang (z.B. PDF-Flyer). */
|
||||
attachment?: {
|
||||
src?: string;
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
|
||||
export type CalendarBlockData = Omit<
|
||||
@@ -330,7 +507,126 @@ export type CalendarBlockData = Omit<
|
||||
_type?: "calendar";
|
||||
/** Optionaler Titel über dem Kalender. */
|
||||
title?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
/** Slugs oder aufgelöste calendar_item-Einträge. */
|
||||
items?: (string | CalendarItemData)[];
|
||||
layout?: BlockLayout;
|
||||
/** Variante: "default" voller Kalender, "compact" Anteaser. */
|
||||
variant?: "default" | "compact";
|
||||
/** Compact: Ziel-Page-Referenz hinter dem "Alle Termine"-Link. */
|
||||
linkTo?: string | { _slug?: string;[key: string]: unknown };
|
||||
/** Compact: Anzahl Termine (default 3). */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/** Internal component block (_type: "internal_component"). */
|
||||
export type InternalComponentId = "form-contact" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
|
||||
|
||||
export interface InternalComponentBlockData {
|
||||
_type?: "internal_component";
|
||||
_slug?: string;
|
||||
component?: InternalComponentId;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
export interface WindMapBlockData {
|
||||
_type?: "wind_map";
|
||||
_slug?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Explizite Auswahl von wind_area-Slugs. Leer/null = alle published areas. */
|
||||
areas?: (string | { _slug: string;[key: string]: unknown })[];
|
||||
/** Optionales Disclaimer-Accordion oberhalb der Karte. HTML/Markdown aus
|
||||
* dem CMS — Komponente rendert es via {@html}. */
|
||||
hinweis_text?: string;
|
||||
/** Beschreibender Fließtext direkt unter dem Titel (Statistik, Interaktions-
|
||||
* Hinweise, Methodik). HTML aus dem CMS. */
|
||||
info_text?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
import type { TextFragment, WindArea } from "./windkarte";
|
||||
|
||||
/** Stellungnahme-Generator (_type: "stellungnahme_generator").
|
||||
* Platzierbare Instanz, rendert Einwendungs-Assistenten für ein Vorranggebiet. */
|
||||
export interface StellingnahmeGeneratorBlockData {
|
||||
_type?: "stellungnahme_generator";
|
||||
_slug?: string;
|
||||
id?: string;
|
||||
/** Referenz auf wind_area; nach Resolve vollständiges WindArea-Objekt inkl.
|
||||
* aufgelöster stellungnahme_kriterien (TextFragment[]). */
|
||||
windArea?: string | (WindArea & {
|
||||
stellungnahme_kriterien?: TextFragment[];
|
||||
});
|
||||
headline?: string;
|
||||
intro?: string;
|
||||
/** Referenz auf tag; nach Resolve: { _slug, name }. */
|
||||
allgemeineArgumenteTag?: string | { _slug: string; name?: string };
|
||||
/** Vom Resolver befüllt: allgemeine (gebietsunabhängige) Fragmente via Tag. */
|
||||
allgemeineArgumente?: TextFragment[];
|
||||
/** Einleitungssatz; {gebiet} wird ersetzt. */
|
||||
defaultOpeningText?: string;
|
||||
/** Abschlussforderung vor "Mit freundlichen Grüßen". */
|
||||
defaultClosingText?: string;
|
||||
/** ISO-Datum: Einwendungsfrist-Ende (z.B. "2026-07-04"). */
|
||||
deadline?: string;
|
||||
/** E-Mail der Planungsstelle — aktiviert Mail-Button. */
|
||||
recipientEmail?: string;
|
||||
/** Vom Resolver befüllt: zentrales KI-Regelwerk aus stellungnahme_config (Singleton). */
|
||||
aiRules?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Einzelne Kachel (info_card): Icon/Bild + Titel + Beschreibung + optionaler Link. */
|
||||
export interface InfoCardData {
|
||||
_type?: "info_card";
|
||||
_slug?: string;
|
||||
/** Iconify-String ("mdi:email") oder Bild-URL — Komponente erkennt automatisch. */
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
linkLabel?: string;
|
||||
linkHref?: string;
|
||||
external?: boolean;
|
||||
/** Farbton der Kachel: default (weiß) oder green (grün getönt). */
|
||||
tone?: "default" | "green";
|
||||
}
|
||||
|
||||
/** Sektions-Grid (_type: "section_grid"): SectionHeader + info_card-Kacheln. */
|
||||
export interface SectionGridBlockData {
|
||||
_type?: "section_grid";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
/** SectionHeader: Label über dem Titel (uppercase). */
|
||||
kicker?: string;
|
||||
/** SectionHeader: Index vor dem Kicker, z.B. "01". */
|
||||
number?: string;
|
||||
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
columns?: "2" | "3" | "4";
|
||||
/** Slugs oder aufgelöste info_card-Einträge. */
|
||||
cards?: (string | InfoCardData)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Zweispaltiger Formular-Block (_type: "contact_form"): Form links, Aside rechts. */
|
||||
export interface ContactFormBlockData {
|
||||
_type?: "contact_form";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
kicker?: string;
|
||||
number?: string;
|
||||
action?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||
form?: "form-contact" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
|
||||
asideTitle?: string;
|
||||
asideText?: string;
|
||||
asideCards?: (string | InfoCardData)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
+244
-50
@@ -4,10 +4,15 @@ import {
|
||||
getPostBySlug,
|
||||
getTags,
|
||||
getTextFragmentBySlug,
|
||||
getTextFragmentsByTag,
|
||||
getEntryBySlug,
|
||||
getStellungnahmeConfig,
|
||||
getCalendarBySlug,
|
||||
getCalendarItemBySlug,
|
||||
getContacts,
|
||||
getCalendarItems,
|
||||
} from "./cms";
|
||||
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
|
||||
import type { RowContentLayout } from "./block-types";
|
||||
import type { CalendarItemData } from "./block-types";
|
||||
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
|
||||
@@ -121,6 +126,44 @@ export function filterPostsByTag(
|
||||
});
|
||||
}
|
||||
|
||||
export type PostEventLocation = {
|
||||
text?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
};
|
||||
|
||||
export type PostEventInfo = {
|
||||
isEvent: boolean;
|
||||
eventDate: string | null;
|
||||
eventLocation: PostEventLocation | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest event-Daten aus dem aufgelösten calendarItem-Reference des Posts.
|
||||
* Voraussetzung: post mit resolve=["all"] oder explizit "calendarItem" geladen,
|
||||
* sodass `post.calendarItem` als Objekt mit terminZeit/location vorliegt.
|
||||
*/
|
||||
export function getPostEventInfo(
|
||||
post: PostEntry | null | undefined,
|
||||
): PostEventInfo {
|
||||
const ci = (post as { calendarItem?: unknown } | null | undefined)?.calendarItem;
|
||||
if (!ci || typeof ci !== "object") {
|
||||
return { isEvent: false, eventDate: null, eventLocation: null };
|
||||
}
|
||||
const obj = ci as {
|
||||
terminZeit?: string;
|
||||
location?: PostEventLocation;
|
||||
};
|
||||
if (!obj.terminZeit) {
|
||||
return { isEvent: false, eventDate: null, eventLocation: null };
|
||||
}
|
||||
return {
|
||||
isEvent: true,
|
||||
eventDate: obj.terminZeit,
|
||||
eventLocation: obj.location ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
|
||||
export function getPostImageUrl(
|
||||
postImage: PostEntry["postImage"],
|
||||
@@ -200,14 +243,14 @@ export type PostSearchIndexEntry = {
|
||||
eventDate: string | null;
|
||||
};
|
||||
|
||||
type PostWithEventIndex = PostEntry & {
|
||||
isEvent?: boolean;
|
||||
eventDate?: string;
|
||||
type PostWithImageIndex = PostEntry & {
|
||||
_resolvedImageUrl?: string;
|
||||
};
|
||||
|
||||
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
||||
return posts.map((p) => ({
|
||||
return posts.map((p) => {
|
||||
const ev = getPostEventInfo(p);
|
||||
return {
|
||||
slug: p.slug ?? p._slug ?? "",
|
||||
headline: p.headline ?? "",
|
||||
excerpt: p.excerpt ?? "",
|
||||
@@ -221,13 +264,16 @@ export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[]
|
||||
"",
|
||||
)
|
||||
.filter(Boolean),
|
||||
image: (p as PostWithEventIndex)._resolvedImageUrl ?? null,
|
||||
image: (p as PostWithImageIndex)._resolvedImageUrl ?? null,
|
||||
created: p.created ?? null,
|
||||
isEvent: Boolean((p as PostWithEventIndex).isEvent),
|
||||
eventDate: (p as PostWithEventIndex).eventDate ?? null,
|
||||
}));
|
||||
isEvent: ev.isEvent,
|
||||
eventDate: ev.eventDate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type PostSortOrder = "newest" | "oldest";
|
||||
|
||||
export type LoadPostsResult = {
|
||||
posts: PostEntry[];
|
||||
tags: Awaited<ReturnType<typeof getTags>>;
|
||||
@@ -235,6 +281,7 @@ export type LoadPostsResult = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalPosts: number;
|
||||
sort: PostSortOrder;
|
||||
upcomingEvents: PostEntry[];
|
||||
searchIndex: PostSearchIndexEntry[];
|
||||
tagName: string | null;
|
||||
@@ -245,9 +292,11 @@ export type LoadPostsResult = {
|
||||
export async function loadPostsList(opts: {
|
||||
tagSlug?: string | null;
|
||||
page?: number;
|
||||
sort?: PostSortOrder;
|
||||
}): Promise<LoadPostsResult> {
|
||||
const tagSlug = opts.tagSlug ?? null;
|
||||
const requestedPage = Math.max(1, opts.page ?? 1);
|
||||
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
|
||||
const perPage = getPostsPerPage();
|
||||
|
||||
let posts: PostEntry[] = [];
|
||||
@@ -255,8 +304,30 @@ export async function loadPostsList(opts: {
|
||||
let cmsError: string | null = null;
|
||||
|
||||
try {
|
||||
// Sparse fieldset für die Listen-Ansicht: Body/Rows brauchen wir hier
|
||||
// nicht, nur Karten-Felder + image + tag-Metadaten. _depth=2 reicht
|
||||
// (post → postImage → img-Sidecar; post → postTag → name/icon/color).
|
||||
[posts, tags] = await Promise.all([
|
||||
getPosts({ resolve: ["postImage"] }),
|
||||
getPosts({
|
||||
resolve: ["postImage", "postTag", "calendarItem"],
|
||||
depth: 2,
|
||||
fields: [
|
||||
"_slug",
|
||||
"slug",
|
||||
"headline",
|
||||
"linkName",
|
||||
"subheadline",
|
||||
"excerpt",
|
||||
"created",
|
||||
"calendarItem",
|
||||
"hideFromListing",
|
||||
"postImage.*",
|
||||
"postTag.name",
|
||||
"postTag.icon",
|
||||
"postTag.color",
|
||||
"postTag._slug",
|
||||
],
|
||||
}),
|
||||
getTags(),
|
||||
]);
|
||||
posts = filterHiddenPosts(sortPostsByDate(posts));
|
||||
@@ -265,8 +336,7 @@ export async function loadPostsList(opts: {
|
||||
for (const post of posts) {
|
||||
const field = getPostImageField(post.postImage);
|
||||
if (field) {
|
||||
try {
|
||||
const url = await ensureTransformedImage(field.url, {
|
||||
const url = ensureTransformedImage(field.url, {
|
||||
width: 400,
|
||||
height: 267,
|
||||
fit: "cover",
|
||||
@@ -282,16 +352,16 @@ export async function loadPostsList(opts: {
|
||||
p._resolvedImageUrl = url;
|
||||
p._rawImageUrl = field.url;
|
||||
p._imageFocal = field.focal ?? null;
|
||||
} catch {
|
||||
/* image optional */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const filtered = filterPostsByTag(posts, tagSlug);
|
||||
const filteredByTag = filterPostsByTag(posts, tagSlug);
|
||||
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
|
||||
const filtered =
|
||||
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pageNum = Math.min(requestedPage, totalPages);
|
||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
@@ -310,6 +380,7 @@ export async function loadPostsList(opts: {
|
||||
currentPage: pageNum,
|
||||
totalPages,
|
||||
totalPosts: filtered.length,
|
||||
sort,
|
||||
upcomingEvents,
|
||||
searchIndex,
|
||||
tagName,
|
||||
@@ -319,16 +390,18 @@ export async function loadPostsList(opts: {
|
||||
|
||||
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[];
|
||||
type Scored = { post: PostEntry; ts: number };
|
||||
const scored: Scored[] = [];
|
||||
for (const p of posts) {
|
||||
const ev = getPostEventInfo(p);
|
||||
if (!ev.isEvent || !ev.eventDate) continue;
|
||||
const ts = new Date(ev.eventDate).getTime();
|
||||
if (ts >= now) scored.push({ post: p, ts });
|
||||
}
|
||||
return scored
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.slice(0, max)
|
||||
.map((s) => s.post);
|
||||
}
|
||||
|
||||
const POST_BASE = "/post";
|
||||
@@ -449,15 +522,14 @@ export async function resolvePostOverviewBlocks(
|
||||
.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;
|
||||
const settled = await Promise.all(
|
||||
slugs.slice(0, limit).map((slug) =>
|
||||
getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
item.postsResolved = settled.filter((p): p is PostEntry => !!p);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(item.postsResolved)) {
|
||||
@@ -468,8 +540,7 @@ export async function resolvePostOverviewBlocks(
|
||||
for (const post of item.postsResolved as PostEntry[]) {
|
||||
const field = getPostImageField(post.postImage);
|
||||
if (field) {
|
||||
try {
|
||||
const url = await ensureTransformedImage(field.url, {
|
||||
const url = ensureTransformedImage(field.url, {
|
||||
width: 400,
|
||||
height: 267,
|
||||
fit: "cover",
|
||||
@@ -485,9 +556,6 @@ export async function resolvePostOverviewBlocks(
|
||||
p._resolvedImageUrl = url;
|
||||
p._rawImageUrl = field.url;
|
||||
p._imageFocal = field.focal ?? null;
|
||||
} catch {
|
||||
// Bild optional
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,19 +607,35 @@ export async function resolveCalendarBlocks(
|
||||
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);
|
||||
// Leere items-Liste → alle calendar_items aus der Collection (Pool).
|
||||
// Greift sowohl für default-Variante als auch compact-Anteaser.
|
||||
if (raw.length === 0) {
|
||||
const pool = (await getCalendarItems({ locale: "de" })) as unknown as CalendarItemData[];
|
||||
(item as { items?: CalendarItemData[] }).items = pool;
|
||||
continue;
|
||||
}
|
||||
const settled = await Promise.all(
|
||||
raw.map(async (x): Promise<CalendarItemData | null> => {
|
||||
if (
|
||||
typeof x === "object" &&
|
||||
x !== null &&
|
||||
"terminZeit" in x &&
|
||||
"title" in x
|
||||
) {
|
||||
return x as CalendarItemData;
|
||||
}
|
||||
}
|
||||
(item as { items?: CalendarItemData[] }).items = resolved;
|
||||
const islug =
|
||||
typeof x === "string" ? x : (x as { _slug?: string })?._slug;
|
||||
if (!islug) return null;
|
||||
const entry = await getCalendarItemBySlug(islug, { locale: "de" }).catch(
|
||||
() => null,
|
||||
);
|
||||
return entry ?? null;
|
||||
}),
|
||||
);
|
||||
(item as { items?: CalendarItemData[] }).items = settled.filter(
|
||||
(e): e is CalendarItemData => !!e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,3 +774,113 @@ export async function resolveSearchableTextBlocks(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStellingnahmeGeneratorBlock(item: unknown): boolean {
|
||||
return (item as { _type?: string })?._type === "stellungnahme_generator";
|
||||
}
|
||||
|
||||
/** Löst stellungnahme_generator-Blöcke auf:
|
||||
* - windArea → vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien
|
||||
* - allgemeineArgumenteTag → allgemeineArgumente[] via Tag-Filter
|
||||
* - aiRules ← zentrale stellungnahme_config (Singleton), einmal geladen, an alle Blöcke gehängt
|
||||
*/
|
||||
export async function resolveStellingnahmeGeneratorBlocks(
|
||||
layout: RowContentLayout | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!layout) return;
|
||||
const rows = [
|
||||
layout.row1Content ?? [],
|
||||
layout.row2Content ?? [],
|
||||
layout.row3Content ?? [],
|
||||
];
|
||||
|
||||
// Zentrales Regelwerk nur laden, wenn überhaupt ein Generator-Block vorhanden ist.
|
||||
const hasGenerator = rows.some(
|
||||
(content) => Array.isArray(content) && content.some(isStellingnahmeGeneratorBlock),
|
||||
);
|
||||
const aiRules = hasGenerator
|
||||
? (await getStellungnahmeConfig(STELLUNGNAHME_CONFIG_SLUG, { locale: "de" }))?.aiRules
|
||||
: undefined;
|
||||
|
||||
for (const content of rows) {
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const item of content) {
|
||||
if (!isStellingnahmeGeneratorBlock(item)) continue;
|
||||
const block = item as Record<string, unknown>;
|
||||
|
||||
// Zentrales Regelwerk anhängen (Fallback auf Hardcode-Default in der Komponente)
|
||||
if (aiRules) block.aiRules = aiRules;
|
||||
|
||||
// Resolve windArea
|
||||
const windAreaRef = block.windArea;
|
||||
const windAreaSlug =
|
||||
typeof windAreaRef === "string"
|
||||
? windAreaRef
|
||||
: (windAreaRef as { _slug?: string })?._slug ?? "";
|
||||
if (windAreaSlug) {
|
||||
const windArea = await getEntryBySlug("wind_area", windAreaSlug, {
|
||||
locale: "de",
|
||||
resolve: ["stellungnahme_kriterien"],
|
||||
depth: 2,
|
||||
});
|
||||
if (windArea) {
|
||||
const kriterienRaw =
|
||||
(windArea as { stellungnahme_kriterien?: unknown[] })
|
||||
.stellungnahme_kriterien ?? [];
|
||||
// Fragmente einzeln laden um bulletPoints zu bekommen (nicht im Basis-Resolve)
|
||||
const kriterien = await Promise.all(
|
||||
kriterienRaw.map(async (k) => {
|
||||
const slug =
|
||||
typeof k === "string"
|
||||
? k
|
||||
: (k as { _slug?: string })?._slug ?? "";
|
||||
if (!slug) return null;
|
||||
return getTextFragmentBySlug(slug, { locale: "de" });
|
||||
}),
|
||||
);
|
||||
(windArea as Record<string, unknown>).stellungnahme_kriterien =
|
||||
kriterien.filter(Boolean);
|
||||
block.windArea = windArea;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve allgemeineArgumente via Tag
|
||||
const tagRef = block.allgemeineArgumenteTag;
|
||||
const tagSlug =
|
||||
typeof tagRef === "string"
|
||||
? tagRef
|
||||
: (tagRef as { _slug?: string })?._slug ?? "";
|
||||
if (tagSlug) {
|
||||
block.allgemeineArgumente = await getTextFragmentsByTag(tagSlug, {
|
||||
locale: "de",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAdressbuchBlocks(
|
||||
layout: RowContentLayout | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!layout) return;
|
||||
const rows = [layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? []];
|
||||
let contacts: unknown[] | null = null;
|
||||
for (const content of rows) {
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const item of content) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
if ((item as { _type?: string })._type !== "adressbuch") continue;
|
||||
if (contacts === null) contacts = await getContacts();
|
||||
const block = item as Record<string, unknown>;
|
||||
const exclude = (block.excludeContacts as unknown[]) ?? [];
|
||||
const excludeSlugs = new Set(
|
||||
exclude.map((e) =>
|
||||
typeof e === "string" ? e : (e as { _slug?: string })?._slug ?? ""
|
||||
).filter(Boolean)
|
||||
);
|
||||
block.resolvedContacts = excludeSlugs.size
|
||||
? contacts.filter((c) => !excludeSlugs.has((c as { _slug?: string })._slug ?? ""))
|
||||
: contacts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Helpers for "send calendar entry to my own calendar" flows.
|
||||
*
|
||||
* - `buildICS()` produces a single-event RFC-5545 string suitable for
|
||||
* download. Apple Calendar, Outlook and Google all import it.
|
||||
* - `googleCalUrl()` returns a deep link to Google Calendar's event-
|
||||
* create form pre-filled with the event details.
|
||||
* - `mapsUrl()` returns a Google Maps search URL for an address string.
|
||||
*
|
||||
* All client-only — no Node APIs touched. Safe in SSR contexts (the
|
||||
* functions just return strings; the actual download is triggered by
|
||||
* the caller via `URL.createObjectURL`).
|
||||
*/
|
||||
|
||||
export type CalendarItemICS = {
|
||||
title: string;
|
||||
start: Date;
|
||||
/** Optional end date. If omitted, default duration is 1 hour. */
|
||||
end?: Date | null;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
/** Stable identifier; falls back to start-timestamp + title hash. */
|
||||
uid?: string;
|
||||
};
|
||||
|
||||
/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients
|
||||
* tolerate longer lines but Outlook is strict — better safe. */
|
||||
function foldLine(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const chunk = line.slice(i, i + (i === 0 ? 75 : 74));
|
||||
out.push((i === 0 ? "" : " ") + chunk);
|
||||
i += i === 0 ? 75 : 74;
|
||||
}
|
||||
return out.join("\r\n");
|
||||
}
|
||||
|
||||
/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */
|
||||
function escapeText(s: string): string {
|
||||
return s
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/;/g, "\\;");
|
||||
}
|
||||
|
||||
/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */
|
||||
function fmtICSDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
d.getUTCFullYear().toString() +
|
||||
pad(d.getUTCMonth() + 1) +
|
||||
pad(d.getUTCDate()) +
|
||||
"T" +
|
||||
pad(d.getUTCHours()) +
|
||||
pad(d.getUTCMinutes()) +
|
||||
pad(d.getUTCSeconds()) +
|
||||
"Z"
|
||||
);
|
||||
}
|
||||
|
||||
function deterministicUid(title: string, start: Date): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = (hash * 31 + title.charCodeAt(i)) | 0;
|
||||
}
|
||||
return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`;
|
||||
}
|
||||
|
||||
export function buildICS(item: CalendarItemICS): string {
|
||||
const start = item.start;
|
||||
const end =
|
||||
item.end ??
|
||||
new Date(start.getTime() + 60 * 60 * 1000); // 1h default
|
||||
const uid = item.uid ?? deterministicUid(item.title, start);
|
||||
const now = new Date();
|
||||
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//windwiderstand.de//calendar//DE",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${fmtICSDate(now)}`,
|
||||
`DTSTART:${fmtICSDate(start)}`,
|
||||
`DTEND:${fmtICSDate(end)}`,
|
||||
foldLine(`SUMMARY:${escapeText(item.title)}`),
|
||||
];
|
||||
if (item.description) {
|
||||
lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
|
||||
}
|
||||
if (item.location) {
|
||||
lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
|
||||
}
|
||||
if (item.url) {
|
||||
lines.push(foldLine(`URL:${item.url}`));
|
||||
}
|
||||
lines.push("END:VEVENT", "END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
/** Trigger a browser download of an ICS file. Caller should pass a
|
||||
* filesystem-friendly filename; we still strip path separators
|
||||
* defensively. No-op on the server (`window` undefined). */
|
||||
export function downloadICS(item: CalendarItemICS, filename: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics";
|
||||
const ics = buildICS(item);
|
||||
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Builds a VCALENDAR with multiple VEVENTs — for feed/bulk export. */
|
||||
export function buildICSFeed(items: CalendarItemICS[], calName = "Windwiderstand Termine"): string {
|
||||
const now = new Date();
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//windwiderstand.de//calendar//DE",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
foldLine(`X-WR-CALNAME:${escapeText(calName)}`),
|
||||
"X-WR-TIMEZONE:Europe/Berlin",
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
|
||||
"X-PUBLISHED-TTL:PT1H",
|
||||
];
|
||||
for (const item of items) {
|
||||
const start = item.start;
|
||||
const end = item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const uid = item.uid ?? deterministicUid(item.title, start);
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${fmtICSDate(now)}`,
|
||||
`DTSTART:${fmtICSDate(start)}`,
|
||||
`DTEND:${fmtICSDate(end)}`,
|
||||
foldLine(`SUMMARY:${escapeText(item.title)}`),
|
||||
);
|
||||
if (item.description) lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
|
||||
if (item.location) lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
|
||||
if (item.url) lines.push(foldLine(`URL:${item.url}`));
|
||||
lines.push("END:VEVENT");
|
||||
}
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
/** Triggers a browser download of a multi-event ICS file. */
|
||||
export function downloadICSFeed(items: CalendarItemICS[], filename: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "termine.ics";
|
||||
const ics = buildICSFeed(items);
|
||||
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Deep-link to Google Calendar's pre-filled create form. */
|
||||
export function googleCalUrl(item: CalendarItemICS): string {
|
||||
const fmt = (d: Date) =>
|
||||
d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
||||
const end = item.end ?? new Date(item.start.getTime() + 60 * 60 * 1000);
|
||||
const params = new URLSearchParams({
|
||||
action: "TEMPLATE",
|
||||
text: item.title,
|
||||
dates: `${fmt(item.start)}/${fmt(end)}`,
|
||||
});
|
||||
if (item.description) params.set("details", item.description);
|
||||
if (item.location) params.set("location", item.location);
|
||||
return `https://calendar.google.com/calendar/render?${params}`;
|
||||
}
|
||||
|
||||
/** OpenStreetMap search URL for an address or place name. */
|
||||
export function mapsUrl(location: string): string {
|
||||
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
|
||||
}
|
||||
@@ -4024,7 +4024,7 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
external: boolean;
|
||||
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'mdi:link'. */
|
||||
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'lucide:link'. */
|
||||
icon?: string;
|
||||
/** @description Internal key. Not used in app. */
|
||||
internal?: string;
|
||||
@@ -4065,7 +4065,7 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
external: boolean;
|
||||
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'mdi:link'. */
|
||||
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'lucide:link'. */
|
||||
icon?: string;
|
||||
/** @description Internal key. Not used in app. */
|
||||
internal?: string;
|
||||
@@ -5400,7 +5400,7 @@ export interface components {
|
||||
readonly _slug?: string;
|
||||
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
||||
color?: string;
|
||||
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
|
||||
/** @description Optional Iconify icon id (e.g. lucide:map-pin, same as link.icon). */
|
||||
icon?: string;
|
||||
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
||||
name: string;
|
||||
@@ -5410,7 +5410,7 @@ export interface components {
|
||||
_slug: string;
|
||||
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
||||
color?: string;
|
||||
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
|
||||
/** @description Optional Iconify icon id (e.g. lucide:map-pin, same as link.icon). */
|
||||
icon?: string;
|
||||
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
||||
name: string;
|
||||
@@ -5446,6 +5446,10 @@ export interface components {
|
||||
id: string;
|
||||
/** @description Banner text (e.g. notice at top of page) */
|
||||
text: string;
|
||||
/** @description Optional URL the banner links to */
|
||||
link?: string;
|
||||
/** @description CTA label for the link */
|
||||
linkLabel?: string;
|
||||
};
|
||||
top_banner_input: {
|
||||
/** @description URL slug (used as filename) */
|
||||
@@ -5454,6 +5458,10 @@ export interface components {
|
||||
id: string;
|
||||
/** @description Banner text (e.g. notice at top of page) */
|
||||
text: string;
|
||||
/** @description Optional URL the banner links to */
|
||||
link?: string;
|
||||
/** @description CTA label for the link */
|
||||
linkLabel?: string;
|
||||
};
|
||||
translation_bundle: {
|
||||
/** @description Entry identifier (filename) */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Health-Probe für die RustyCMS API. Ergebnisse werden kurz gecacht, damit nicht
|
||||
* jeder Request einen extra Roundtrip auslöst. Bei Negativ-Status kürzere TTL,
|
||||
* damit das Frontend zügig wieder hochkommt sobald das CMS antwortet.
|
||||
*/
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const HEALTHY_TTL_MS = 10_000;
|
||||
const UNHEALTHY_TTL_MS = 3_000;
|
||||
const PROBE_TIMEOUT_MS = 1_500;
|
||||
|
||||
type CacheEntry = { healthy: boolean; expires: number };
|
||||
let cached: CacheEntry | null = null;
|
||||
let inflight: Promise<boolean> | null = null;
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const url = env.PUBLIC_CMS_URL || 'http://localhost:3000';
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async function probe(): Promise<boolean> {
|
||||
const base = getBaseUrl();
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`, {
|
||||
method: 'GET',
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCmsAvailable(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (cached && cached.expires > now) return cached.healthy;
|
||||
if (inflight) return inflight;
|
||||
|
||||
const p = (async () => {
|
||||
const healthy = await probe();
|
||||
cached = {
|
||||
healthy,
|
||||
expires: Date.now() + (healthy ? HEALTHY_TTL_MS : UNHEALTHY_TTL_MS),
|
||||
};
|
||||
return healthy;
|
||||
})();
|
||||
inflight = p;
|
||||
p.finally(() => {
|
||||
if (inflight === p) inflight = null;
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
export function resetCmsHealthCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const ORIG_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.PUBLIC_CMS_URL = 'https://cms.example.com';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIG_ENV };
|
||||
});
|
||||
|
||||
async function loadResolver() {
|
||||
const mod = await import('./cms-source.server');
|
||||
return mod.resolveCmsSource;
|
||||
}
|
||||
|
||||
describe('resolveCmsSource', () => {
|
||||
it('accepts asset filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('photo.jpg')).toBe('https://cms.example.com/api/assets/photo.jpg');
|
||||
});
|
||||
|
||||
it('accepts absolute path on CMS host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('/api/assets/foo.png')).toBe('https://cms.example.com/api/assets/foo.png');
|
||||
});
|
||||
|
||||
it('accepts full URL on CMS host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('https://cms.example.com/some/path')).toBe('https://cms.example.com/some/path');
|
||||
});
|
||||
|
||||
it('rejects foreign host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('https://attacker.com/exploit.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects http when CMS uses https', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('http://cms.example.com/foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects protocol-relative URLs', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('//attacker.com/x.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects path traversal in filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('../etc/passwd')).toBeNull();
|
||||
expect(resolve('./hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('encodes "javascript:" payloads as harmless asset filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
// Kein Schema-Marker (kein "://") → wird als Filename behandelt und
|
||||
// url-encoded an /api/assets angehängt. Server liefert 404 zurück,
|
||||
// keine Code-Ausführung möglich.
|
||||
expect(resolve('javascript:alert(1)')).toContain('/api/assets/javascript%3Aalert');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Allowlist-Resolver für `?src=...`-Query-Parameter in den Image- und File-Proxy-
|
||||
* Routen. Verhindert SSRF: Angreifer könnten sonst beliebige URLs an die
|
||||
* CMS-API durchreichen lassen (interne Hosts, Cloud-Metadata-Endpunkte,
|
||||
* Cache-Pollution).
|
||||
*
|
||||
* Erlaubt:
|
||||
* - Asset-Dateiname ("foo.jpg") → {CMS}/api/assets/foo.jpg
|
||||
* - Absoluter Pfad ("/api/assets/...") → {CMS}/api/assets/...
|
||||
* - Vollständige URL exakt unter CMS-Host → unverändert
|
||||
*
|
||||
* Abgelehnt:
|
||||
* - Fremde Hosts, andere Schemata
|
||||
* - Protokoll-relative URLs ("//host/...")
|
||||
* - Pfad-Traversal ("..", führender Punkt)
|
||||
*/
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export function getCmsBaseUrl(): string {
|
||||
return (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
'http://localhost:3000'
|
||||
).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function resolveCmsSource(src: string): string | null {
|
||||
if (src.startsWith('//')) return null;
|
||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
let candidate: URL;
|
||||
let cmsHost: URL;
|
||||
try {
|
||||
candidate = new URL(src);
|
||||
cmsHost = new URL(getCmsBaseUrl());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (candidate.host !== cmsHost.host) return null;
|
||||
if (candidate.protocol !== cmsHost.protocol) return null;
|
||||
return src;
|
||||
}
|
||||
if (src.includes('://') || src.includes('..') || src.startsWith('.')) return null;
|
||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
||||
}
|
||||
+460
-44
@@ -15,6 +15,24 @@ const getBaseUrl = (): string => {
|
||||
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei
|
||||
* langsamem oder unerreichbarem CMS. Override per Call via `init.signal`
|
||||
* oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS =
|
||||
Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000;
|
||||
|
||||
/**
|
||||
* Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal`
|
||||
* mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout`
|
||||
* gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`.
|
||||
*/
|
||||
async function cmsFetch(input: string | URL, init?: RequestInit): Promise<Response> {
|
||||
const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
return fetch(input, { ...init, signal });
|
||||
}
|
||||
|
||||
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
|
||||
export type PageEntry = components["schemas"]["page"];
|
||||
|
||||
@@ -44,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
|
||||
* 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,
|
||||
// Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
|
||||
// Cold-Loads + CMS-Roundtrips.
|
||||
page: 60 * 60_000, // 1h (war 5min)
|
||||
page_config: 60 * 60_000, // 1h
|
||||
navigation: 60 * 60_000, // 1h
|
||||
footer: 60 * 60_000, // 1h
|
||||
post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
|
||||
tag: 30 * 60_000, // 30min
|
||||
text_fragment: 30 * 60_000, // 30min
|
||||
fullwidth_banner: 60 * 60_000, // 1h
|
||||
calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
|
||||
calendar_item: 5 * 60_000, // 5min
|
||||
translation: 60 * 60_000, // 1h
|
||||
translation_bundle: 60 * 60_000, // 1h
|
||||
openapi: 60 * 60_000, // 1h
|
||||
default: 5 * 60_000,
|
||||
};
|
||||
|
||||
type CacheEntry<T> = { value: T; expires: number };
|
||||
@@ -70,14 +90,19 @@ function ttlFor(collection: string): number {
|
||||
|
||||
/**
|
||||
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
|
||||
* Bei Fehler wird nichts gecacht.
|
||||
* Multi-Collection-Einträge (z.B. Layout-Bootstrap-Batch) nutzen eine CSV-Liste
|
||||
* als Key-Head: "navigation,footer:op:" — invalidateCollection purged dann bei
|
||||
* Mutation jeder gelisteten Collection. Bei Fehler wird nichts gecacht.
|
||||
*/
|
||||
async function cached<T>(
|
||||
export async function cached<T>(
|
||||
collection: string,
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (import.meta.env.DEV) return fn();
|
||||
// Dev: Cache aus by default (sofortige Reflektion neuer CMS-Daten).
|
||||
// Mit DEV_CACHE_CMS=1 anschalten — sinnvoll wenn lokal gegen Prod-CMS
|
||||
// entwickelt wird, sonst werden Netzwerk-Roundtrips bei jedem Request fällig.
|
||||
if (import.meta.env.DEV && process.env.DEV_CACHE_CMS !== "1") return fn();
|
||||
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;
|
||||
@@ -99,7 +124,9 @@ async function cached<T>(
|
||||
export function invalidateCollection(collection: string): number {
|
||||
let n = 0;
|
||||
for (const k of cache.keys()) {
|
||||
if (k.startsWith(`${collection}:`)) {
|
||||
const sep = k.indexOf(':');
|
||||
if (sep === -1) continue;
|
||||
if (k.slice(0, sep).split(',').includes(collection)) {
|
||||
cache.delete(k);
|
||||
n++;
|
||||
}
|
||||
@@ -125,7 +152,7 @@ export function cacheStats(): { size: number; keys: string[] } {
|
||||
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
if (openApiCache && !import.meta.env.DEV) return openApiCache;
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
||||
const res = await cmsFetch(`${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}?`,
|
||||
@@ -142,14 +169,35 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
return cached("page", "page:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
// Alle Seiten holen — CMS-Default-per_page ist 50, die Site hat aber mehr.
|
||||
// Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
|
||||
// getPageBySlug für Seiten jenseits der ersten Page → 404.
|
||||
const PER_PAGE = 200;
|
||||
// Sparse-Projektion: nur die Felder, die Caller (sitemap, search-index,
|
||||
// root-page, getPageBySlug-Fallback) tatsächlich verwenden. Spart 80–95 %
|
||||
// Payload-Größe und vermeidet komplette Resolve-Trees.
|
||||
const FIELDS =
|
||||
"_slug,_updated,_created,slug,linkName,name,headline,subheadline";
|
||||
const all: PageEntry[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const res = await cmsFetch(
|
||||
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}&_fields=${FIELDS}`,
|
||||
);
|
||||
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 ?? [];
|
||||
const data = (await res.json()) as PageListResponse & {
|
||||
total_pages?: number;
|
||||
};
|
||||
all.push(...(data.items ?? []));
|
||||
totalPages = data.total_pages ?? 1;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
return all;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,7 +216,7 @@ export async function getPageConfigs(options?: {
|
||||
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());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -195,7 +243,7 @@ export async function getPageConfigBySlug(
|
||||
);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -229,15 +277,113 @@ export type ContentGetOptions = {
|
||||
locale?: string;
|
||||
/** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
|
||||
resolve?: string[];
|
||||
/** Cap für Resolve-Rekursionstiefe (0..=10). Ohne Wert: legacy unbegrenzt. */
|
||||
depth?: number;
|
||||
/** Sparse-Fieldset, z. B. ["title", "author.name"]. Punktnotation triggert implicit _resolve. */
|
||||
fields?: string[];
|
||||
/** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */
|
||||
preview?: string;
|
||||
/** Signierte Live-Preview-Session-ID — Server rendert In-Memory-Draft statt persistierten Eintrag. Bypass Cache. */
|
||||
previewDraft?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch endpoint (POST /api/content/_batch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Eine Sub-Anfrage im Batch. `id` wird im Response als Schlüssel zurückgegeben. */
|
||||
export type BatchRequest = {
|
||||
id: string;
|
||||
collection: string;
|
||||
slug: string;
|
||||
environment?: string;
|
||||
locale?: string;
|
||||
resolve?: string;
|
||||
depth?: number;
|
||||
fields?: string[];
|
||||
preview?: string;
|
||||
raw_assets?: boolean;
|
||||
};
|
||||
|
||||
/** Pro-Sub-Result: Erfolg (`status: 200, data`) oder Fehler (`status: 4xx/5xx, error`). */
|
||||
export type BatchResult<T = unknown> =
|
||||
| { status: 200; data: T }
|
||||
| { status: number; error: string };
|
||||
|
||||
/** Antwort von POST /api/content/_batch. */
|
||||
export type BatchResponse = { results: Record<string, BatchResult> };
|
||||
|
||||
/**
|
||||
* Schickt mehrere Lookups in einem HTTP-Roundtrip an den CMS-Batch-Endpoint.
|
||||
*
|
||||
* - Outer-Status ist immer 200 wenn der Envelope ok ist; pro Sub-Request
|
||||
* findet sich der eigentliche Status in `results.<id>.status`.
|
||||
* - Cap pro Call wird vom Server erzwungen (`RUSTYCMS_BATCH_MAX_REQUESTS`,
|
||||
* default 50). Über dem Cap → 400 mit Detail-Message.
|
||||
* - Kein TTL-Cache: Batch-Calls sind POST und tragen einen serialisierten
|
||||
* Body, der sich pro Call unterscheidet. Wer Cache will, soll die
|
||||
* Single-GET-Funktionen nehmen.
|
||||
*/
|
||||
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
|
||||
if (requests.length === 0) return { results: {} };
|
||||
const base = getBaseUrl();
|
||||
const res = await cmsFetch(`${base}/api/content/_batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requests }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`RustyCMS batch failed: ${res.status} ${res.statusText}. ${detail}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as BatchResponse;
|
||||
}
|
||||
|
||||
/** Type-narrowing-Helper: liefert `data`, wenn der Sub-Request 200 lieferte, sonst `null`. */
|
||||
export function batchData<T>(result: BatchResult | undefined): T | null {
|
||||
if (!result) return null;
|
||||
return result.status === 200 ? (result as { data: T }).data : null;
|
||||
}
|
||||
|
||||
/** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */
|
||||
function normalizePageSlug(s: string | undefined): string {
|
||||
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlanker Lookup: gibt zu einem URL-Slug ("/regionalplan/w36" oder
|
||||
* "regionalplan/w36") den internen `_slug` zurück, indem das CMS gezielt nach
|
||||
* dem `slug`-Feld gefiltert wird. Nur `_slug` wird projiziert → kleine Payload
|
||||
* unabhängig von Collection-Größe. Probiert beide Slug-Schreibweisen.
|
||||
*/
|
||||
async function findPageSlugByField(
|
||||
normSlug: string,
|
||||
base: string,
|
||||
): Promise<string | null> {
|
||||
const candidates = [normSlug, "/" + normSlug];
|
||||
for (const candidate of candidates) {
|
||||
const u = new URL(`${base}/api/content/page`);
|
||||
u.searchParams.set("slug", candidate);
|
||||
u.searchParams.set("_fields", "_slug");
|
||||
u.searchParams.set("_per_page", "2");
|
||||
let r: Response;
|
||||
try {
|
||||
r = await cmsFetch(u.toString());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) continue;
|
||||
const data = (await r.json().catch(() => null)) as
|
||||
| { items?: Array<{ _slug?: string }> }
|
||||
| null;
|
||||
const hit = data?.items?.[0]?._slug;
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
|
||||
*/
|
||||
@@ -246,7 +392,9 @@ export async function getPageBySlug(
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PageEntry | null> {
|
||||
const resolveKey = options?.resolve?.join(",") ?? "";
|
||||
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
|
||||
const fieldsKey = options?.fields?.join(",") ?? "";
|
||||
const depthKey = options?.depth ?? "";
|
||||
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
|
||||
const fetchFn = async () => {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = async (s: string): Promise<Response> => {
|
||||
@@ -254,8 +402,13 @@ export async function getPageBySlug(
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
if (typeof options?.depth === "number")
|
||||
u.searchParams.set("_depth", String(options.depth));
|
||||
if (options?.fields?.length)
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
return fetch(u.toString());
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePageSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -263,6 +416,18 @@ export async function getPageBySlug(
|
||||
res = await trySlug("/" + normSlug);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// O(1)-Lookup via slug-Feld-Filter (statt komplette Page-Liste zu holen).
|
||||
// Skaliert egal wie viele Pages existieren. Beide Slug-Schreibweisen
|
||||
// probieren — Filter macht exakten String-Vergleich.
|
||||
const matchSlug = await findPageSlugByField(normSlug, base);
|
||||
if (matchSlug) {
|
||||
res = await trySlug(matchSlug);
|
||||
}
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// Last-resort: vollständige Liste durchsuchen (Safety-Net für Edge-Cases
|
||||
// — z.B. Slug-Formen die der Exakt-Filter verfehlt aber post-Normalisierung
|
||||
// matchen würden). Sollte praktisch nie greifen.
|
||||
const items = await getPages();
|
||||
const match = items.find(
|
||||
(p) =>
|
||||
@@ -281,7 +446,7 @@ export async function getPageBySlug(
|
||||
}
|
||||
return (await res.json()) as PageEntry;
|
||||
};
|
||||
if (options?.preview) return fetchFn();
|
||||
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||
return cached("page", key, fetchFn);
|
||||
}
|
||||
|
||||
@@ -291,6 +456,55 @@ export async function getPageSlugs(): Promise<string[]> {
|
||||
return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Magere Page-Stub-Daten für Navigation/Slug-Maps — nur Label-Felder, kein Body. */
|
||||
export type PageStub = Pick<
|
||||
PageEntry,
|
||||
"_slug" | "slug" | "linkName" | "name" | "headline"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Page-Liste auf nur die Felder reduziert, die für Slug→Label/Slug→URL-Maps
|
||||
* gebraucht werden. Spart Body/Rows — typisch Faktor 10-50× kleiner.
|
||||
*/
|
||||
export async function getPageStubs(options?: { locale?: string }): Promise<PageStub[]> {
|
||||
const locale = options?.locale ?? "";
|
||||
const key = `page:stubs:${locale}`;
|
||||
return cached("page", key, async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/page`);
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PageStub[] };
|
||||
return data.items ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Magere Post-Stubs für Navigation/Slug-Maps — kein Body, keine Rows. */
|
||||
export type PostStub = Pick<
|
||||
PostEntry,
|
||||
"_slug" | "slug" | "linkName" | "headline"
|
||||
>;
|
||||
|
||||
/** Post-Liste reduziert auf Slug+Label-Felder. */
|
||||
export async function getPostStubs(options?: { locale?: string }): Promise<PostStub[]> {
|
||||
const locale = options?.locale ?? "";
|
||||
const key = `post:stubs:${locale}`;
|
||||
return cached("post", key, async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/post`);
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PostStub[] };
|
||||
return data.items ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). */
|
||||
export type NavigationEntry = components["schemas"]["navigation"];
|
||||
|
||||
@@ -327,7 +541,7 @@ export async function getNavigations(options?: {
|
||||
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());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -387,7 +601,7 @@ export async function getNavigationBySlug(
|
||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NavigationEntry;
|
||||
@@ -412,7 +626,7 @@ export async function getFooterBySlug(
|
||||
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());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -439,7 +653,9 @@ export async function getPostBySlug(
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PostEntry | null> {
|
||||
const resolveKey = options?.resolve?.join(",") ?? "";
|
||||
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
|
||||
const fieldsKey = options?.fields?.join(",") ?? "";
|
||||
const depthKey = options?.depth ?? "";
|
||||
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
|
||||
const fetchFn = async () => {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = async (s: string): Promise<Response> => {
|
||||
@@ -447,8 +663,13 @@ export async function getPostBySlug(
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
if (typeof options?.depth === "number")
|
||||
u.searchParams.set("_depth", String(options.depth));
|
||||
if (options?.fields?.length)
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
return fetch(u.toString());
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePostSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -474,22 +695,62 @@ export async function getPostBySlug(
|
||||
}
|
||||
return (await res.json()) as PostEntry;
|
||||
};
|
||||
if (options?.preview) return fetchFn();
|
||||
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||
return cached("post", key, fetchFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic single-entry fetch by `(collection, slug)`. Used by the embed/widget
|
||||
* route that renders a single block standalone.
|
||||
*
|
||||
* Resolves nested references via `_resolve=all` by default so block components
|
||||
* see the same data shape as inside a page row.
|
||||
*/
|
||||
export async function getEntryBySlug<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<T | null> {
|
||||
const resolveKey = options?.resolve?.join(",") ?? "all";
|
||||
const fieldsKey = options?.fields?.join(",") ?? "";
|
||||
const depthKey = options?.depth ?? "";
|
||||
const key = `entry:${collection}:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
|
||||
const fetchFn = async (): Promise<T | null> => {
|
||||
const base = getBaseUrl();
|
||||
const u = new URL(`${base}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}`);
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
u.searchParams.set("_resolve", options?.resolve?.length ? options.resolve.join(",") : "all");
|
||||
if (typeof options?.depth === "number") u.searchParams.set("_depth", String(options.depth));
|
||||
if (options?.fields?.length) u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
const res = await cmsFetch(u.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`RustyCMS getEntry failed: ${res.status} for ${collection}/${slug}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||
return cached(collection, key, fetchFn);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve, _depth, _fields). */
|
||||
export interface GetPostsOptions {
|
||||
_sort?: string;
|
||||
_order?: "asc" | "desc";
|
||||
/** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
|
||||
resolve?: string | string[];
|
||||
/** Cap für Resolve-Rekursionstiefe (0..=10). */
|
||||
depth?: number;
|
||||
/** Sparse-Fieldset, z. B. ["headline", "excerpt", "postImage.*", "postTag.name"]. */
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
/** Alle Post-Einträge (GET /api/content/post). Gecacht pro Request. */
|
||||
@@ -500,7 +761,9 @@ export async function getPosts(
|
||||
const resolveVal = Array.isArray(opts.resolve)
|
||||
? opts.resolve.join(",")
|
||||
: (opts.resolve ?? "");
|
||||
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
|
||||
const fieldsVal = opts.fields?.join(",") ?? "";
|
||||
const depthVal = opts.depth ?? "";
|
||||
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}:${depthVal}:${fieldsVal}`;
|
||||
return cached("post", key, async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/post`);
|
||||
@@ -508,7 +771,9 @@ export async function getPosts(
|
||||
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 (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
|
||||
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||
const data = (await res.json()) as { items?: PostEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -522,7 +787,7 @@ export type TagEntry = components["schemas"]["tag"];
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
return cached("tag", "tag:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/tag`);
|
||||
const res = await cmsFetch(`${base}/api/content/tag`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TagEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -544,7 +809,7 @@ export async function getTextFragmentBySlug(
|
||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -555,7 +820,58 @@ export async function getTextFragmentBySlug(
|
||||
});
|
||||
}
|
||||
|
||||
/** Alle Text-Fragmente, die einen bestimmten Tag (Slug) tragen. */
|
||||
export async function getTextFragmentsByTag(
|
||||
tagSlug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<TextFragmentEntry[]> {
|
||||
const key = `text_fragment:tag:${tagSlug}:${options?.locale ?? ""}`;
|
||||
return cached("text_fragment", key, async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/text_fragment`);
|
||||
url.searchParams.set("_per_page", "500");
|
||||
url.searchParams.set("_resolve", "tags");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TextFragmentEntry[] };
|
||||
const items = data.items ?? [];
|
||||
return items.filter((f) => {
|
||||
const tags = (f as unknown as { tags?: unknown[] }).tags ?? [];
|
||||
return tags.some((t) => {
|
||||
if (typeof t === "string") return t === tagSlug;
|
||||
return (t as { _slug?: string })?._slug === tagSlug;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
|
||||
export type TopBannerEntry = components["schemas"]["top_banner"];
|
||||
|
||||
/** Top-Banner anhand Slug (GET /api/content/top_banner/:slug). */
|
||||
export async function getTopBannerBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<TopBannerEntry | null> {
|
||||
const key = `top_banner:slug:${slug}:${options?.locale ?? ""}`;
|
||||
return cached("top_banner", key, async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/top_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS get top_banner failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as TopBannerEntry;
|
||||
});
|
||||
}
|
||||
|
||||
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];
|
||||
|
||||
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
|
||||
@@ -570,7 +886,7 @@ export async function getFullwidthBannerBySlug(
|
||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -596,7 +912,7 @@ export async function getTranslationBySlug(
|
||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationEntry;
|
||||
@@ -624,13 +940,65 @@ export async function getTranslationBundleBySlug(
|
||||
);
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationBundleEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/** Zentrale Stellungnahme-Generator-Konfiguration (Singleton). */
|
||||
export type StellungnahmeConfigEntry = {
|
||||
aiRules?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lädt das zentrale Regelwerk für die Stellungnahme-Generatoren
|
||||
* (GET /api/content/stellungnahme_config/:slug). Einmal definiert, von allen
|
||||
* 40 Generator-Blöcken genutzt statt pro Block dupliziert.
|
||||
*/
|
||||
export async function getStellungnahmeConfig(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<StellungnahmeConfigEntry | null> {
|
||||
return getEntryBySlug<StellungnahmeConfigEntry>(
|
||||
"stellungnahme_config",
|
||||
slug,
|
||||
{ locale: options?.locale },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalised key→string map for `event.locals.translations`. Cached on the
|
||||
* raw `strings` object identity so the per-request `Object.entries` loop runs
|
||||
* once per bundle reload (every 5 min) instead of every request.
|
||||
*/
|
||||
const normalizedTranslations = new WeakMap<object, Record<string, string>>();
|
||||
|
||||
export async function getNormalizedTranslations(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<Record<string, string>> {
|
||||
const bundle = await getTranslationBundleBySlug(slug, options);
|
||||
const strings = bundle?.strings;
|
||||
if (!strings || typeof strings !== "object") return {};
|
||||
const cached = normalizedTranslations.get(strings as object);
|
||||
if (cached) return cached;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(strings as Record<string, unknown>)) {
|
||||
const str =
|
||||
typeof val === "string"
|
||||
? val
|
||||
: typeof val === "object" && val !== null && "value" in val
|
||||
? (val as { value?: unknown }).value
|
||||
: (val as { text?: unknown })?.text ??
|
||||
(val as { label?: unknown })?.label;
|
||||
if (typeof str === "string") out[key] = str;
|
||||
}
|
||||
normalizedTranslations.set(strings as object, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */
|
||||
export type CalendarEntry = components["schemas"]["calendar"];
|
||||
|
||||
@@ -650,7 +1018,7 @@ export async function getCalendarBySlug(
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarEntry;
|
||||
@@ -669,7 +1037,7 @@ export async function getCalendarItemBySlug(
|
||||
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarItemEntry;
|
||||
@@ -687,9 +1055,57 @@ export async function getCalendarItems(
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: CalendarItemEntry[] };
|
||||
return data.items ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetches approved comment counts for many page_ids in one request.
|
||||
* Hits the comment plugin's `/api/comments/counts` endpoint. Returns a map
|
||||
* `{ pageId → count }` with `0` for any id missing in the response.
|
||||
*
|
||||
* The comment-plugin endpoints live behind a different env (`PUBLIC_CMS_URL`)
|
||||
* and may be on a different origin in dev. SSR-side calls always hit it
|
||||
* directly; the cookie sync of the visitor session does NOT happen here
|
||||
* (counts are public anyway).
|
||||
*/
|
||||
export async function getCommentCounts(
|
||||
pageIds: string[],
|
||||
options?: { environment?: string },
|
||||
): Promise<Record<string, number>> {
|
||||
if (pageIds.length === 0) return {};
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/comments/counts`);
|
||||
url.searchParams.set("ids", pageIds.join(","));
|
||||
if (options?.environment) {
|
||||
url.searchParams.set("_environment", options.environment);
|
||||
}
|
||||
try {
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return {};
|
||||
const data = (await res.json()) as { counts?: Record<string, number> };
|
||||
return data.counts ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Kontakte (GET /api/content/contact), alphabetisch nach Name. */
|
||||
export async function getContacts(): Promise<Record<string, unknown>[]> {
|
||||
return cached("contact", "contact:list:all", async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/contact`);
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
url.searchParams.set("_resolve", "all");
|
||||
url.searchParams.set("_depth", "2");
|
||||
url.searchParams.set("_sort", "name");
|
||||
url.searchParams.set("_order", "asc");
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: Record<string, unknown>[] };
|
||||
return data.items ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Helper for fetching comment counts in bulk and keying them by post slug.
|
||||
* Used by overview components (BlogOverview, PostOverviewBlock) so each
|
||||
* render fires a single `/api/comments/counts?ids=…` request instead of
|
||||
* one request per card.
|
||||
*/
|
||||
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
|
||||
export type CommentCountsMap = Record<string, number>;
|
||||
|
||||
/** Strip the leading slash so it matches `params.slug` (= comment page_id). */
|
||||
export function postSlugForComments(p: PostEntry): string {
|
||||
const raw = (p as { slug?: string; _slug?: string }).slug ?? p._slug ?? "";
|
||||
return raw.replace(/^\//, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetch approved comment counts for many posts. Returns a map
|
||||
* `{ slug: count }` (slug as in `postSlugForComments`). Missing or failed
|
||||
* responses resolve to an empty object — callers can then `?? null` per slug.
|
||||
*/
|
||||
export async function fetchCommentCounts(
|
||||
slugs: string[],
|
||||
options?: { environment?: string; signal?: AbortSignal },
|
||||
): Promise<CommentCountsMap> {
|
||||
const cleaned = slugs.filter(Boolean);
|
||||
if (cleaned.length === 0) return {};
|
||||
const cmsBase = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
""
|
||||
).replace(/\/$/, "");
|
||||
if (!cmsBase) return {};
|
||||
const url = new URL(`${cmsBase}/api/comments/counts`);
|
||||
url.searchParams.set("ids", cleaned.join(","));
|
||||
if (options?.environment) {
|
||||
url.searchParams.set("_environment", options.environment);
|
||||
}
|
||||
try {
|
||||
const r = await fetch(url.toString(), { signal: options?.signal });
|
||||
if (!r.ok) return {};
|
||||
const d = (await r.json()) as { counts?: CommentCountsMap };
|
||||
return d?.counts ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Accordion from './Accordion.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Molecules/Accordion',
|
||||
component: Accordion,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
open: { control: 'boolean' },
|
||||
lazy: { control: 'boolean' },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Geschlossen" args={{ label: 'Details anzeigen' }}>
|
||||
<p class="p-4">Aufklappbarer Inhalt mit beliebigem Markup.</p>
|
||||
</Story>
|
||||
|
||||
<Story name="Offen" args={{ label: 'Bereits offen', open: true }}>
|
||||
<p class="p-4">Dieser Abschnitt startet aufgeklappt.</p>
|
||||
</Story>
|
||||
@@ -32,9 +32,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<details {id} {open} ontoggle={onToggle} class="pt-4 border-t border-zinc-200 group {cls}">
|
||||
<details {id} {open} ontoggle={onToggle} class="pt-4 border-t border-stein-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" />
|
||||
<Icon icon="lucide:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
|
||||
{label}
|
||||
</summary>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { TopBannerEntry } from '$lib/cms';
|
||||
import { marked } from '$lib/markdown-safe';
|
||||
|
||||
let { banner }: { banner: TopBannerEntry } = $props();
|
||||
|
||||
const textHtml = $derived(banner?.text ? marked.parseInline(banner.text) as string : '');
|
||||
</script>
|
||||
|
||||
{#if banner?.text}
|
||||
<div class="w-full bg-wald-600 text-white print:hidden" role="note" style="font-size: 0.7rem;">
|
||||
<div class="container-custom flex items-center gap-3 py-2 md:justify-center md:text-center">
|
||||
<span class="banner-text">{@html textHtml}</span>
|
||||
{#if banner.link}
|
||||
<a
|
||||
href={banner.link}
|
||||
class="shrink-0 font-semibold underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{banner.linkLabel ?? 'Mehr erfahren'} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Leading **bold** in the banner text renders as a red "NEU"-style badge. */
|
||||
.banner-text :global(strong:first-child) {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.3em;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-fire-600);
|
||||
color: #fff;
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import ArrowLink from './ArrowLink.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/ArrowLink',
|
||||
component: ArrowLink,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
args: { href: '#', label: 'Mehr erfahren' },
|
||||
argTypes: {
|
||||
size: { control: 'select', options: ['sm', 'md'] },
|
||||
tone: { control: 'select', options: ['himmel', 'wald', 'stein', 'white'] },
|
||||
external: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof ArrowLink>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Medium: Story = { args: { size: 'md', label: 'Zum Adressbuch' } };
|
||||
export const Klein: Story = { args: { size: 'sm', label: 'cloud.windwiderstand.de' } };
|
||||
export const Extern: Story = { args: { size: 'sm', label: 'Beitreten', external: true } };
|
||||
export const Wald: Story = { args: { tone: 'wald', label: 'Alle Termine' } };
|
||||
export const Stein: Story = { args: { tone: 'stein', size: 'sm', label: 'Alle Beiträge' } };
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ArrowLink — Text-Link mit Pfeil, himmel-Farbe. Die eine Quelle für die
|
||||
* „… →"-Links (SectionHeader-CTA, InfoCard, Sektions-Aktionen).
|
||||
* `size`: sm (kompakt, z.B. in Cards) · md (Sektionsköpfe).
|
||||
*/
|
||||
type Size = "sm" | "md";
|
||||
type Tone = "himmel" | "wald" | "stein" | "white";
|
||||
|
||||
let {
|
||||
href,
|
||||
label,
|
||||
external = false,
|
||||
size = "md",
|
||||
tone = "himmel",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
size?: Size;
|
||||
tone?: Tone;
|
||||
} = $props();
|
||||
|
||||
const textClass: Record<Size, string> = {
|
||||
sm: "text-[13px]",
|
||||
md: "text-[15px]",
|
||||
};
|
||||
const arrowClass: Record<Size, string> = {
|
||||
sm: "size-3.5",
|
||||
md: "size-4",
|
||||
};
|
||||
const toneClass: Record<Tone, string> = {
|
||||
himmel: "text-himmel-500 hover:text-himmel-600",
|
||||
wald: "text-wald-700 hover:text-wald-800",
|
||||
stein: "text-stein-600 hover:text-stein-900",
|
||||
white: "text-white hover:text-white/80",
|
||||
};
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
class="inline-flex items-center gap-1.5 font-semibold no-underline {toneClass[tone]} {textClass[size]}"
|
||||
>
|
||||
{label}
|
||||
<svg class={arrowClass[size]} viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</a>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Badge from './Badge.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Molecules/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['green', 'blue', 'amber', 'orange', 'purple', 'teal', 'slate', 'fire', 'white', undefined],
|
||||
},
|
||||
variant: { control: 'select', options: ['subtle', 'solid'] },
|
||||
uppercase: { control: 'boolean' },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Die eine Badge. `color` deckt alle CMS-badge.color-Werte ab. -->
|
||||
<Story name="Default">Neu</Story>
|
||||
<Story name="Green" args={{ color: 'green' }}>Gründer</Story>
|
||||
<Story name="Blue" args={{ color: 'blue' }}>Mitglied</Story>
|
||||
<Story name="Amber" args={{ color: 'amber' }}>Anfrage läuft</Story>
|
||||
<Story name="Orange" args={{ color: 'orange' }}>Südwestthüringen</Story>
|
||||
<Story name="Purple" args={{ color: 'purple' }}>Ostthüringen</Story>
|
||||
<Story name="Teal" args={{ color: 'teal' }}>Mittelthüringen</Story>
|
||||
<Story name="Slate" args={{ color: 'slate' }}>Nordthüringen</Story>
|
||||
|
||||
<!-- Solid (gefüllt, weißer Text) — Kategorie/Status-Pills. -->
|
||||
<Story name="Solid Green" args={{ variant: 'solid', color: 'green', uppercase: true }}>Neu</Story>
|
||||
<Story name="Solid Slate" args={{ variant: 'solid', color: 'slate', uppercase: true }}>Vergangen</Story>
|
||||
<Story name="Solid Fire" args={{ variant: 'solid', color: 'fire', uppercase: true }}>Live</Story>
|
||||
@@ -1,22 +1,63 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Variant = "subtle" | "solid";
|
||||
|
||||
let {
|
||||
children,
|
||||
color,
|
||||
variant = "subtle",
|
||||
uppercase = false,
|
||||
class: className = "",
|
||||
}: { children?: Snippet; color?: string; class?: string } = $props();
|
||||
}: {
|
||||
children?: Snippet;
|
||||
color?: string;
|
||||
/** subtle = getönt mit Border (Default) · solid = gefüllt, weißer Text. */
|
||||
variant?: Variant;
|
||||
uppercase?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
function colorClasses(c?: string): string {
|
||||
// Die einzige Badge-Farbquelle. Deckt alle CMS-badge.color-Werte ab
|
||||
// (green/blue/amber/orange/purple/teal/slate) + Fallback. Marken-Töne
|
||||
// (wald/himmel) wo möglich, sonst Tailwind-Palette. KEINE Eigenimplementierungen
|
||||
// woanders — immer <Badge>.
|
||||
function subtleClasses(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";
|
||||
case "green": return "bg-wald-50 text-wald-700 border-wald-200";
|
||||
case "blue": return "bg-himmel-50 text-himmel-700 border-himmel-200";
|
||||
case "amber": return "bg-amber-50 text-amber-700 border-amber-200";
|
||||
case "orange": return "bg-orange-50 text-orange-700 border-orange-200";
|
||||
case "purple": return "bg-purple-50 text-purple-700 border-purple-200";
|
||||
case "teal": return "bg-teal-50 text-teal-700 border-teal-200";
|
||||
case "slate": return "bg-slate-100 text-slate-700 border-slate-300";
|
||||
default: return "bg-stein-100 text-stein-600 border-stein-200";
|
||||
}
|
||||
}
|
||||
// Gefüllte Variante (weißer Text) — für Kategorie-/Status-Pills auf Bildern etc.
|
||||
function solidClasses(c?: string): string {
|
||||
switch (c) {
|
||||
case "green": return "bg-wald-600 text-white border-transparent";
|
||||
case "blue": return "bg-himmel-700 text-white border-transparent";
|
||||
case "amber": return "bg-amber-500 text-white border-transparent";
|
||||
case "orange": return "bg-orange-500 text-white border-transparent";
|
||||
case "purple": return "bg-purple-600 text-white border-transparent";
|
||||
case "teal": return "bg-teal-600 text-white border-transparent";
|
||||
case "fire": return "bg-fire-700 text-white border-transparent";
|
||||
case "white": return "bg-white/90 text-stein-800 border-transparent";
|
||||
case "slate":
|
||||
default: return "bg-stein-800 text-white border-transparent";
|
||||
}
|
||||
}
|
||||
const colorClasses = $derived(
|
||||
variant === "solid" ? solidClasses(color) : subtleClasses(color),
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="shrink-0 rounded-full px-1.5 py-0 text-[0.65rem] font-medium {colorClasses(color)} {className}">
|
||||
<span
|
||||
class="shrink-0 whitespace-nowrap rounded-full border px-1.5 py-0 text-[0.65rem] font-medium {uppercase
|
||||
? 'uppercase tracking-wide'
|
||||
: ''} {colorClasses} {className}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<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 FilesBlock from "./blocks/FilesBlock.svelte";
|
||||
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
||||
import YoutubeVideoGalleryBlock from "./blocks/YoutubeVideoGalleryBlock.svelte";
|
||||
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
||||
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.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 CalendarCompactBlock from "./blocks/CalendarCompactBlock.svelte";
|
||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||
import ContactsBlock from "./blocks/ContactsBlock.svelte";
|
||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
|
||||
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
||||
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
|
||||
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
|
||||
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
|
||||
import ContactFormBlock from "./blocks/ContactFormBlock.svelte";
|
||||
import type { Translations } from "$lib/translations";
|
||||
import type {
|
||||
ResolvedBlock,
|
||||
AdressbuchBlockData,
|
||||
MarkdownBlockData,
|
||||
HeadlineBlockData,
|
||||
HtmlBlockData,
|
||||
IframeBlockData,
|
||||
ImageBlockData,
|
||||
ImageGalleryBlockData,
|
||||
FilesBlockData,
|
||||
YoutubeVideoBlockData,
|
||||
YoutubeVideoGalleryBlockData,
|
||||
QuoteBlockData,
|
||||
QuoteCarouselBlockData,
|
||||
LinkListBlockData,
|
||||
PostOverviewBlockData,
|
||||
SearchableTextBlockData,
|
||||
CalendarBlockData,
|
||||
OrganisationsBlockData,
|
||||
ContactsBlockData,
|
||||
OpnFormBlockData,
|
||||
InternalComponentBlockData,
|
||||
DeadlineBannerBlockData,
|
||||
LiveStrommixBlockData,
|
||||
WindMapBlockData,
|
||||
StellingnahmeGeneratorBlockData,
|
||||
OrganisationsMapBlockData,
|
||||
SectionGridBlockData,
|
||||
ContactFormBlockData,
|
||||
} from "$lib/block-types";
|
||||
|
||||
let {
|
||||
block,
|
||||
translations = {},
|
||||
}: { block: ResolvedBlock; translations?: Translations | null } = $props();
|
||||
|
||||
const type = $derived((block._type as string) ?? "unknown");
|
||||
</script>
|
||||
|
||||
{#if type === "markdown"}
|
||||
<MarkdownBlock block={block as MarkdownBlockData} />
|
||||
{:else if type === "headline"}
|
||||
<HeadlineBlock block={block as HeadlineBlockData} />
|
||||
{:else if type === "html"}
|
||||
<HtmlBlock block={block as HtmlBlockData} />
|
||||
{:else if type === "iframe"}
|
||||
<IframeBlock block={block as IframeBlockData} translations={translations} />
|
||||
{:else if type === "image"}
|
||||
<ImageBlock block={block as ImageBlockData} />
|
||||
{:else if type === "image_gallery"}
|
||||
<ImageGalleryBlock block={block as ImageGalleryBlockData} />
|
||||
{:else if type === "files"}
|
||||
<FilesBlock block={block as FilesBlockData} />
|
||||
{:else if type === "youtube_video"}
|
||||
<YoutubeVideoBlock block={block as YoutubeVideoBlockData} />
|
||||
{:else if type === "youtube_video_gallery"}
|
||||
<YoutubeVideoGalleryBlock block={block as YoutubeVideoGalleryBlockData} />
|
||||
{:else if type === "quote"}
|
||||
<QuoteBlock block={block as QuoteBlockData} />
|
||||
{:else if type === "quote_carousel"}
|
||||
<QuoteCarouselBlock block={block as QuoteCarouselBlockData} />
|
||||
{:else if type === "link_list"}
|
||||
<LinkListBlock block={block as LinkListBlockData} />
|
||||
{:else if type === "post_overview"}
|
||||
<PostOverviewBlock block={block as PostOverviewBlockData} />
|
||||
{:else if type === "searchable_text"}
|
||||
<SearchableTextBlock block={block as SearchableTextBlockData} translations={translations} />
|
||||
{:else if type === "calendar"}
|
||||
{#if (block as CalendarBlockData).variant === "compact"}
|
||||
<CalendarCompactBlock block={block as CalendarBlockData} translations={translations} />
|
||||
{:else}
|
||||
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
|
||||
{/if}
|
||||
{:else if type === "organisations"}
|
||||
<OrganisationsBlock block={block as OrganisationsBlockData} />
|
||||
{:else if type === "contacts_block"}
|
||||
<ContactsBlock block={block as ContactsBlockData} />
|
||||
{:else if type === "opnform"}
|
||||
<OpnFormBlock block={block as OpnFormBlockData} />
|
||||
{:else if type === "internal_component"}
|
||||
<InternalComponentBlock block={block as InternalComponentBlockData} />
|
||||
{:else if type === "deadline_banner"}
|
||||
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
|
||||
{:else if type === "live_strommix"}
|
||||
<!-- Lazy: ~220 KB Block-Bundle nur laden wenn Seite das Widget tatsächlich hat -->
|
||||
{#await import("./blocks/StrommixBlock.svelte") then m}
|
||||
{@const Comp = m.default}
|
||||
<Comp block={block as LiveStrommixBlockData} />
|
||||
{/await}
|
||||
{:else if type === "wind_map"}
|
||||
<WindkarteBlock block={block as WindMapBlockData} />
|
||||
{:else if type === "adressbuch"}
|
||||
<AdressbuchBlock block={block as AdressbuchBlockData} />
|
||||
{:else if type === "organisations_map"}
|
||||
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
|
||||
{:else if type === "section_grid"}
|
||||
<SectionGridBlock block={block as SectionGridBlockData} />
|
||||
{:else if type === "contact_form"}
|
||||
<ContactFormBlock block={block as ContactFormBlockData} />
|
||||
{:else if type === "stellungnahme_generator"}
|
||||
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
|
||||
{@const Comp = m.default}
|
||||
<Comp block={block as StellingnahmeGeneratorBlockData} />
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
|
||||
Unbekannter Block: <code>{type}</code>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,12 +2,33 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import EventBadges from "./EventBadges.svelte";
|
||||
import Tag from "./Tag.svelte";
|
||||
import Pagination from "./Pagination.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Badge from "./Badge.svelte";
|
||||
import PostMeta from "./PostMeta.svelte";
|
||||
import RustyImage from "./RustyImage.svelte";
|
||||
import SearchField from "$lib/ui/SearchField.svelte";
|
||||
import NewsletterInlineFormComponent from "./internal/NewsletterInlineFormComponent.svelte";
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import { postHref } from "$lib/blog-utils";
|
||||
import { postHref, getPostEventInfo } from "$lib/blog-utils";
|
||||
import type { PostSortOrder } from "$lib/blog-utils";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
type PostWithImage = PostEntry & {
|
||||
_resolvedImageUrl?: string;
|
||||
_rawImageUrl?: string;
|
||||
_imageFocal?: { x: number; y: number } | null;
|
||||
};
|
||||
import { t as tStatic, T } from "$lib/translations";
|
||||
import type { Translations } from "$lib/translations";
|
||||
import {
|
||||
fetchCommentCounts,
|
||||
postSlugForComments,
|
||||
type CommentCountsMap,
|
||||
} from "$lib/comment-counts";
|
||||
|
||||
interface TagEntry {
|
||||
_slug?: string;
|
||||
@@ -28,8 +49,6 @@
|
||||
eventDate: string | null;
|
||||
};
|
||||
|
||||
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
|
||||
|
||||
let {
|
||||
posts = [],
|
||||
tags = [],
|
||||
@@ -41,6 +60,7 @@
|
||||
translations = {},
|
||||
upcomingEvents = [],
|
||||
searchIndex = null,
|
||||
sort = "newest",
|
||||
}: {
|
||||
posts: PostEntry[];
|
||||
tags: TagEntry[];
|
||||
@@ -50,12 +70,81 @@
|
||||
totalPosts?: number;
|
||||
basePath?: string;
|
||||
translations?: Translations | null;
|
||||
upcomingEvents?: EventPost[];
|
||||
upcomingEvents?: PostEntry[];
|
||||
searchIndex?: SearchIndexEntry[] | null;
|
||||
sort?: PostSortOrder;
|
||||
} = $props();
|
||||
|
||||
// Sortierung umschalten: Query-Param `sort`, immer zurück auf Seite 1
|
||||
// (bei umgekehrter Reihenfolge stimmen alte Seitenzahlen nicht mehr).
|
||||
function setSort(order: PostSortOrder) {
|
||||
if (order === sort) return;
|
||||
const base = activeTag
|
||||
? `${basePath}/tag/${encodeURIComponent(activeTag)}/`
|
||||
: `${basePath}/`;
|
||||
goto(order === "oldest" ? `${base}?sort=oldest` : base);
|
||||
}
|
||||
|
||||
let query = $state("");
|
||||
let year = $state<string>("");
|
||||
|
||||
// Nur Tags anzeigen, die auch in mindestens einem Beitrag vorkommen
|
||||
// (searchIndex = alle listbaren Posts, tags als Namen). Aktiver Tag bleibt
|
||||
// immer sichtbar. Ohne searchIndex nichts filtern.
|
||||
const usedTagNames = $derived(
|
||||
new Set((searchIndex ?? []).flatMap((e) => e.tags)),
|
||||
);
|
||||
const isTagUsed = (t: TagEntry) =>
|
||||
!searchIndex ||
|
||||
usedTagNames.has(t.name) ||
|
||||
(t._slug ?? "") === activeTag;
|
||||
|
||||
// Org-/BI-Tags (slug `tag-org-*`, name „Org · …") in eigene Sektion trennen.
|
||||
const isOrgTag = (t: TagEntry) => (t._slug ?? "").startsWith("tag-org-");
|
||||
const orgTags = $derived(tags.filter((t) => isOrgTag(t) && isTagUsed(t)));
|
||||
const topicTags = $derived(
|
||||
tags.filter((t) => !isOrgTag(t) && isTagUsed(t)),
|
||||
);
|
||||
const orgLabel = (name: string) => name.replace(/^Org\s*·\s*/, "");
|
||||
const activeIsOrg = $derived(
|
||||
!!activeTag && orgTags.some((t) => (t._slug ?? "") === activeTag),
|
||||
);
|
||||
|
||||
// Lange Themen-Liste einklappen (erste N + „mehr"). Der aktive Tag wird
|
||||
// auch im eingeklappten Zustand immer angezeigt.
|
||||
const INITIAL_TAGS = 12;
|
||||
let showAllTags = $state(false);
|
||||
const visibleTags = $derived.by(() => {
|
||||
if (showAllTags) return topicTags;
|
||||
const head = topicTags.slice(0, INITIAL_TAGS);
|
||||
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
|
||||
const act = topicTags.find((t) => (t._slug ?? "") === activeTag);
|
||||
if (act) return [...head, act];
|
||||
}
|
||||
return head;
|
||||
});
|
||||
let showAllOrgs = $state(false);
|
||||
const INITIAL_ORGS = 10;
|
||||
const visibleOrgs = $derived.by(() => {
|
||||
if (showAllOrgs) return orgTags;
|
||||
const head = orgTags.slice(0, INITIAL_ORGS);
|
||||
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
|
||||
const act = orgTags.find((t) => (t._slug ?? "") === activeTag);
|
||||
if (act) return [...head, act];
|
||||
}
|
||||
return head;
|
||||
});
|
||||
|
||||
// Featured-Hero: neuester Beitrag groß, nur auf Seite 1 der ungefilterten
|
||||
// Übersicht. Danach die restlichen Posts im Grid.
|
||||
const showFeatured = $derived(
|
||||
currentPage === 1 && !activeTag && sort === "newest" && posts.length > 1,
|
||||
);
|
||||
const featuredPost = $derived(showFeatured ? posts[0] : null);
|
||||
const gridPosts = $derived(showFeatured ? posts.slice(1) : posts);
|
||||
const featuredEvent = $derived(
|
||||
featuredPost ? getPostEventInfo(featuredPost) : null,
|
||||
);
|
||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
||||
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
||||
|
||||
@@ -83,7 +172,8 @@
|
||||
.filter((p) => {
|
||||
if (year && yearOf(p) !== year) return false;
|
||||
if (q) {
|
||||
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
|
||||
const hay =
|
||||
`${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -96,13 +186,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -113,50 +196,50 @@
|
||||
}
|
||||
|
||||
const totalCount = $derived(totalPosts);
|
||||
|
||||
// Bulk-fetch comment counts for the visible posts (single request per render).
|
||||
let commentCounts = $state<CommentCountsMap>({});
|
||||
$effect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetchCommentCounts(posts.map(postSlugForComments), {
|
||||
signal: ctrl.signal,
|
||||
}).then((map) => {
|
||||
commentCounts = map;
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
</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"
|
||||
<SearchField
|
||||
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"
|
||||
ariaLabel={t(T.blog_search_label)}
|
||||
class="w-full sm:max-w-sm"
|
||||
/>
|
||||
{#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
|
||||
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
|
||||
>
|
||||
<Icon icon="lucide: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"
|
||||
class="appearance-none rounded-md border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
|
||||
>
|
||||
<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
|
||||
class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-stein-500"
|
||||
>
|
||||
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
@@ -164,28 +247,37 @@
|
||||
{/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" />
|
||||
<Card variant="callout" class="not-prose mb-6">
|
||||
<h2
|
||||
class="mb-1 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700"
|
||||
>
|
||||
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
|
||||
{t(T.blog_upcoming_events)}
|
||||
</h2>
|
||||
<ul class="space-y-2">
|
||||
<ul class="not-prose list-none space-y-2 pl-0">
|
||||
{#each upcomingEvents as ev}
|
||||
{@const evInfo = getPostEventInfo(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
|
||||
href={postHref(ev)}
|
||||
class="group flex flex-wrap items-center gap-2 no-underline"
|
||||
>
|
||||
<EventBadges eventDate={evInfo.eventDate} />
|
||||
<span
|
||||
class="text-sm text-stein-900 group-hover:text-stein-900"
|
||||
>{ev.headline}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if isFiltering}
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600">
|
||||
<div
|
||||
class="mb-3 flex flex-wrap items-center gap-2 text-xs text-stein-600"
|
||||
>
|
||||
<span>
|
||||
{t(T.blog_results_count, { count: filteredResults.length })}
|
||||
{#if query}{t(T.blog_results_for, { query })}{/if}
|
||||
@@ -193,44 +285,71 @@
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { query = ""; year = ""; }}
|
||||
class="text-zinc-500 underline hover:text-zinc-900"
|
||||
>{t(T.blog_filter_reset)}</button>
|
||||
onclick={() => {
|
||||
query = "";
|
||||
year = "";
|
||||
}}
|
||||
class="text-stein-500 underline hover:text-stein-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">
|
||||
<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">
|
||||
<Card
|
||||
variant="tile"
|
||||
layout="post"
|
||||
href={searchResultHref(hit.slug)}
|
||||
>
|
||||
{#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
|
||||
class="aspect-3/2 w-full overflow-hidden bg-stein-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>
|
||||
<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>
|
||||
<p class="text-sm text-stein-700 line-clamp-3">
|
||||
{hit.excerpt}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
|
||||
<div
|
||||
class="rounded-lg border border-stein-200 bg-white p-6 text-center text-sm text-stein-600"
|
||||
>
|
||||
{t(T.blog_no_results)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if tags.length > 0}
|
||||
{#if topicTags.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
|
||||
<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}
|
||||
{#each visibleTags as tag}
|
||||
{@const slug = tag._slug ?? ""}
|
||||
<Tag
|
||||
label={tag.name}
|
||||
@@ -238,40 +357,191 @@
|
||||
variant={activeTag === slug ? "inactive" : "green"}
|
||||
active={activeTag === slug}
|
||||
icon={tag.icon?.trim() || null}
|
||||
customColor={activeTag === slug ? null : tag.color?.trim() || null}
|
||||
customColor={activeTag === slug
|
||||
? null
|
||||
: tag.color?.trim() || null}
|
||||
/>
|
||||
{/each}
|
||||
{#if topicTags.length > INITIAL_TAGS}
|
||||
<Tag
|
||||
label={showAllTags
|
||||
? "weniger"
|
||||
: `+${topicTags.length - INITIAL_TAGS} weitere`}
|
||||
variant="white"
|
||||
icon={showAllTags ? "lucide:chevron-up" : "lucide:chevron-down"}
|
||||
onclick={() => (showAllTags = !showAllTags)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Pagination
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{basePath}
|
||||
activeTag={activeTag}
|
||||
{#if orgTags.length > 0}
|
||||
<div class="mb-4 mt-5">
|
||||
<div
|
||||
class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-stein-500"
|
||||
>
|
||||
<Icon icon="lucide:users" class="size-3.5" />
|
||||
Bürgerinitiativen & Organisationen
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="navigation"
|
||||
aria-label="Nach Organisation filtern"
|
||||
>
|
||||
{#each visibleOrgs as tag}
|
||||
{@const slug = tag._slug ?? ""}
|
||||
<Tag
|
||||
label={orgLabel(tag.name)}
|
||||
href={tagHref(slug)}
|
||||
variant={activeTag === slug ? "inactive" : "white"}
|
||||
active={activeTag === slug}
|
||||
icon={tag.icon?.trim() || "lucide:flag"}
|
||||
/>
|
||||
{/each}
|
||||
{#if orgTags.length > INITIAL_ORGS && !activeIsOrg}
|
||||
<Tag
|
||||
label={showAllOrgs
|
||||
? "weniger"
|
||||
: `+${orgTags.length - INITIAL_ORGS} weitere`}
|
||||
variant="white"
|
||||
icon={showAllOrgs ? "lucide:chevron-up" : "lucide:chevron-down"}
|
||||
onclick={() => (showAllOrgs = !showAllOrgs)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 mb-4 flex flex-wrap items-center gap-2">
|
||||
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
|
||||
<div class="grow"></div>
|
||||
<div
|
||||
class="inline-flex overflow-hidden rounded-full border border-stein-200 bg-white text-xs"
|
||||
role="group"
|
||||
aria-label="Sortierung"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setSort("newest")}
|
||||
aria-pressed={sort === "newest"}
|
||||
class="px-3 py-1.5 font-medium transition-colors {sort === 'newest'
|
||||
? 'bg-wald-600 text-white'
|
||||
: 'text-stein-600 hover:bg-stein-50'}"
|
||||
>
|
||||
Neueste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setSort("oldest")}
|
||||
aria-pressed={sort === "oldest"}
|
||||
class="px-3 py-1.5 font-medium transition-colors {sort === 'oldest'
|
||||
? 'bg-wald-600 text-white'
|
||||
: 'text-stein-600 hover:bg-stein-50'}"
|
||||
>
|
||||
Älteste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if featuredPost}
|
||||
{@const fp = featuredPost as PostWithImage}
|
||||
<a
|
||||
href={postHref(fp)}
|
||||
class="not-prose group mb-6 grid overflow-hidden rounded-lg border border-stein-200 bg-white no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2 md:grid-cols-2"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden bg-stein-100 md:aspect-auto">
|
||||
{#if fp._rawImageUrl}
|
||||
<RustyImage
|
||||
src={fp._rawImageUrl}
|
||||
focal={fp._imageFocal ?? null}
|
||||
width={720}
|
||||
aspect="16/9"
|
||||
alt={fp.headline ?? ""}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if fp._resolvedImageUrl}
|
||||
<img
|
||||
src={fp._resolvedImageUrl}
|
||||
alt={fp.headline ?? ""}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<Badge
|
||||
variant="solid"
|
||||
color="green"
|
||||
uppercase
|
||||
class="absolute left-3 top-3 inline-flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
<Icon icon="lucide:star" class="size-3" />
|
||||
Neuester Beitrag
|
||||
</Badge>
|
||||
{#if featuredEvent?.eventDate}
|
||||
<div class="absolute bottom-3 left-3 right-3 min-w-0">
|
||||
<EventBadges
|
||||
eventDate={featuredEvent.eventDate}
|
||||
locationText={featuredEvent.eventLocation?.text ?? null}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col justify-center p-5 md:p-6">
|
||||
<PostMeta
|
||||
date={fp.created ?? null}
|
||||
tags={fp.postTag ?? []}
|
||||
tagBase={`${basePath}/tag`}
|
||||
/>
|
||||
<h2 class="mb-2 mt-1 text-xl font-semibold leading-tight text-stein-900 md:text-2xl">
|
||||
{fp.headline ?? fp.linkName ?? fp.slug ?? fp._slug}
|
||||
</h2>
|
||||
{#if fp.subheadline}
|
||||
<div class="mb-2 text-sm text-stein-700">
|
||||
{@html marked.parseInline(fp.subheadline)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if fp.excerpt}
|
||||
<p class="line-clamp-3 text-sm font-light text-stein-600">
|
||||
{fp.excerpt}
|
||||
</p>
|
||||
{/if}
|
||||
<span
|
||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-wald-700 group-hover:gap-1.5"
|
||||
>
|
||||
Weiterlesen
|
||||
<Icon
|
||||
icon="lucide:arrow-right"
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each posts as post}
|
||||
{#each gridPosts as post}
|
||||
<PostCard
|
||||
post={post}
|
||||
{post}
|
||||
href={postHref(post)}
|
||||
tagBase={`${basePath}/tag`}
|
||||
commentCount={commentCounts[postSlugForComments(post)] ??
|
||||
null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if currentPage === 1 && !activeTag}
|
||||
<div
|
||||
class="not-prose my-6 rounded-lg bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
|
||||
>
|
||||
<NewsletterInlineFormComponent />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Pagination
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{basePath}
|
||||
activeTag={activeTag}
|
||||
/>
|
||||
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
|
||||
<div class="grow"></div>
|
||||
<div class="bg-white rounded-md font-thin">
|
||||
<div class="inline-flex text-xs p-2">
|
||||
<Badge>
|
||||
{#if totalCount > 0}
|
||||
{t(T.blog_count, {
|
||||
visible: posts.length,
|
||||
@@ -287,13 +557,15 @@
|
||||
totalPages,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</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" />
|
||||
<div class="mt-5">
|
||||
<a
|
||||
href="/posts/rss.xml"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-stein-200 bg-white px-3.5 py-2 text-sm font-medium text-stein-700 no-underline transition-colors hover:border-wald-300 hover:bg-stein-50 hover:text-wald-700"
|
||||
>
|
||||
<Icon icon="lucide:rss" class="size-4 text-wald-600" />
|
||||
{t(T.blog_rss_subscribe)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Molecules/Card',
|
||||
component: Card,
|
||||
tags: ['autodocs'],
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Tile">
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold">Karten-Titel</h3>
|
||||
<p class="text-sm text-stein-600">Inhalt der Kachel.</p>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="AlsLink" args={{ href: '#', variant: 'tile' }}>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold">Klickbare Karte</h3>
|
||||
<p class="text-sm text-stein-600">Ganze Karte ist ein Link.</p>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -7,7 +7,7 @@
|
||||
* tile + post: PostCard – Shell ohne Padding, Medien bündig, Text mit p-4 im Kind.
|
||||
* callout: CTA; layout wird ignoriert.
|
||||
*/
|
||||
const cardSurface = cva("relative min-w-0 overflow-hidden", {
|
||||
const cardSurface = cva("relative min-w-0 overflow-hidden rounded-lg", {
|
||||
variants: {
|
||||
variant: {
|
||||
tile: "",
|
||||
@@ -24,20 +24,17 @@
|
||||
{
|
||||
variant: "tile",
|
||||
layout: "stack",
|
||||
class:
|
||||
"flex flex-col gap-2 border border-gray-200 bg-white p-4 text-slate-950",
|
||||
class: "card-surface flex flex-col gap-2 p-4 text-stein-900",
|
||||
},
|
||||
{
|
||||
variant: "tile",
|
||||
layout: "inline",
|
||||
class:
|
||||
"flex flex-row items-start gap-3 border border-gray-200 bg-white p-4 text-slate-950",
|
||||
class: "card-surface flex flex-row items-start gap-3 p-4 text-stein-900",
|
||||
},
|
||||
{
|
||||
variant: "tile",
|
||||
layout: "post",
|
||||
class:
|
||||
"flex flex-col gap-0 border border-gray-200 bg-white p-0 text-slate-950",
|
||||
class: "card-surface flex flex-col gap-0 p-0 text-stein-900",
|
||||
},
|
||||
],
|
||||
defaultVariants: { variant: "tile", layout: "stack" },
|
||||
@@ -46,8 +43,8 @@
|
||||
const cardAsLink = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
callout: "cursor-pointer transition-all no-underline text-inherit",
|
||||
tile: "transition-all no-underline hover:border-gray-300",
|
||||
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
|
||||
tile: "card-hover-lift no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "tile" },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Carousel from './Carousel.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Organisms/Carousel',
|
||||
component: Carousel,
|
||||
tags: ['autodocs'],
|
||||
});
|
||||
|
||||
const colors = ['wald', 'erde', 'himmel', 'fire', 'stein'];
|
||||
</script>
|
||||
|
||||
<!-- Carousel bekommt die Anzahl + eine `item`-Snippet, die pro Index rendert. -->
|
||||
<Story name="Standard" asChild>
|
||||
<Carousel count={5} ariaLabel="Demo-Karussell">
|
||||
{#snippet item(i)}
|
||||
<div class="flex h-40 items-center justify-center rounded-sm bg-{colors[i]}-100 text-xl font-semibold">
|
||||
Slide {i + 1}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Carousel>
|
||||
</Story>
|
||||
|
||||
<Story name="AutoRotate" asChild>
|
||||
<Carousel count={3} autoRotateMs={2000} ariaLabel="Auto-Karussell">
|
||||
{#snippet item(i)}
|
||||
<div class="flex h-40 items-center justify-center rounded-sm bg-wald-100 text-xl font-semibold">
|
||||
Auto {i + 1}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Carousel>
|
||||
</Story>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import IconButton from "$lib/components/IconButton.svelte";
|
||||
|
||||
let {
|
||||
count,
|
||||
item,
|
||||
ariaLabel = "Karussell",
|
||||
autoRotateMs = 0,
|
||||
showArrows = true,
|
||||
}: {
|
||||
count: number;
|
||||
item: Snippet<[index: number]>;
|
||||
ariaLabel?: string;
|
||||
autoRotateMs?: number;
|
||||
showArrows?: boolean;
|
||||
} = $props();
|
||||
|
||||
let index = $state(0);
|
||||
let paused = $state(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let touchStartX = 0;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
if (Math.abs(dx) < 40) return;
|
||||
if (dx < 0) next(); else prev();
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (count > 0) index = (index - 1 + count) % count;
|
||||
}
|
||||
function next() {
|
||||
if (count > 0) index = (index + 1) % count;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
if (!autoRotateMs || paused || count < 2) return;
|
||||
timer = setInterval(() => { index = (index + 1) % count; }, autoRotateMs);
|
||||
}
|
||||
function stopTimer() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void autoRotateMs; void paused; void count;
|
||||
startTimer();
|
||||
return stopTimer;
|
||||
});
|
||||
|
||||
onMount(startTimer);
|
||||
onDestroy(stopTimer);
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={ariaLabel}
|
||||
onmouseenter={() => (paused = true)}
|
||||
onmouseleave={() => (paused = false)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||
{#if showArrows && count > 1}
|
||||
<IconButton icon="lucide:chevron-left" label="Vorheriger Eintrag" variant="surface" size="sm" onclick={prev} />
|
||||
{/if}
|
||||
|
||||
<div class="grid flex-1 min-w-0" ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
|
||||
{#each { length: count } as _, i}
|
||||
<div
|
||||
class="col-start-1 row-start-1 transition-opacity duration-300 {i === index
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
aria-hidden={i !== index}
|
||||
>
|
||||
{@render item(i)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showArrows && count > 1}
|
||||
<IconButton icon="lucide:chevron-right" label="Nächster Eintrag" variant="surface" size="sm" onclick={next} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if count > 1}
|
||||
<div class="mt-3 flex justify-center gap-1.5" role="tablist">
|
||||
{#each { length: count } as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`Eintrag ${i + 1}`}
|
||||
onclick={() => (index = i)}
|
||||
class="size-2 rounded-full transition-colors {i === index
|
||||
? 'bg-wald-700'
|
||||
: 'bg-stein-300 hover:bg-stein-400'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
let {
|
||||
pageId,
|
||||
environment,
|
||||
targetId = "comments-section",
|
||||
class: cls = "",
|
||||
}: {
|
||||
pageId: string;
|
||||
environment?: string;
|
||||
targetId?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let count = $state<number | null>(null);
|
||||
|
||||
const cmsBase = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
$effect(() => {
|
||||
const url = `${cmsBase}/api/comments/${encodeURIComponent(pageId)}/count${
|
||||
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
|
||||
}`;
|
||||
let cancelled = false;
|
||||
fetch(url)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
count = typeof d?.count === "number" ? d.count : 0;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) count = 0;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const el = document.getElementById(targetId) as HTMLDetailsElement | null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
el.open = true;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="#{targetId}"
|
||||
onclick={onClick}
|
||||
aria-label="Zu Kommentaren springen"
|
||||
class="no-underline! inline-flex items-center gap-1 text-xs font-medium transition-colors {cls}"
|
||||
>
|
||||
<Icon icon="mdi:comment-outline" class="size-4" />
|
||||
<span class="tabular-nums">{count ?? "…"}</span>
|
||||
</a>
|
||||
@@ -2,6 +2,10 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import TextInput from "$lib/ui/TextInput.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
|
||||
let {
|
||||
pageId,
|
||||
@@ -15,6 +19,8 @@
|
||||
bodyMax?: number;
|
||||
} = $props();
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
type Status = "approved" | "pending" | "spam";
|
||||
|
||||
type PublicComment = {
|
||||
@@ -187,7 +193,7 @@
|
||||
}
|
||||
|
||||
async function deleteComment(id: string) {
|
||||
if (!confirm("Kommentar löschen?")) return;
|
||||
if (!confirm(t(T.comments_confirm_delete))) return;
|
||||
try {
|
||||
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
|
||||
method: "DELETE",
|
||||
@@ -302,43 +308,40 @@
|
||||
bind:value={editBody}
|
||||
use:autosize
|
||||
onkeydown={submitOnCmdEnter}
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
||||
rows="2"
|
||||
maxlength={bodyMax}
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
|
||||
>Speichern</button>
|
||||
<Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEdit}
|
||||
class="text-zinc-500 hover:text-zinc-900"
|
||||
>Abbrechen</button>
|
||||
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
||||
class="text-stein-500 hover:text-stein-900"
|
||||
>{t(T.comments_cancel)}</button>
|
||||
<span class="ml-auto text-stein-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
||||
</div>
|
||||
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
|
||||
{#if editError}<p class="text-fire-700 text-xs mt-1">{editError}</p>{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800">{c.body}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-stein-800">{c.body}</p>
|
||||
{#if c.can_edit}
|
||||
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEdit(c)}
|
||||
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
||||
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:pencil-outline" class="size-3" />
|
||||
Bearbeiten
|
||||
<Icon icon="lucide:pencil" class="size-3" />
|
||||
{t(T.comments_edit)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteComment(c.id)}
|
||||
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1"
|
||||
class="text-xs text-stein-600 hover:text-fire-700 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:trash-can-outline" class="size-3" />
|
||||
Löschen
|
||||
<Icon icon="lucide:trash-2" class="size-3" />
|
||||
{t(T.comments_delete)}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -347,59 +350,59 @@
|
||||
|
||||
{#snippet meta(c: PublicComment)}
|
||||
<div class="flex flex-wrap items-baseline gap-2 text-sm">
|
||||
<strong class="font-medium text-zinc-900">{c.author}</strong>
|
||||
<strong class="font-medium text-stein-900">{c.author}</strong>
|
||||
{#if c.can_edit}
|
||||
<span class="text-[10px] uppercase tracking-wide text-zinc-400">du</span>
|
||||
<span class="text-[10px] uppercase tracking-wide text-stein-400">{t(T.comments_self_marker)}</span>
|
||||
{/if}
|
||||
<time
|
||||
class="text-zinc-500 text-xs"
|
||||
class="text-stein-500 text-xs"
|
||||
datetime={c.created_at}
|
||||
title={fmtAbs(c.created_at)}
|
||||
>{fmtRel(c.created_at)}</time>
|
||||
{#if c.updated_at}
|
||||
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>(bearbeitet)</span>
|
||||
<span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
|
||||
{/if}
|
||||
{#if c.status === "pending"}
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800">
|
||||
<Icon icon="mdi:clock-outline" class="size-3" />
|
||||
wartet
|
||||
</span>
|
||||
<Badge color="amber" uppercase class="ml-auto inline-flex items-center gap-1">
|
||||
<Icon icon="lucide:clock" class="size-3" />
|
||||
{t(T.comments_pending_short)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="comments {cls}">
|
||||
{#if loading && comments.length === 0}
|
||||
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label="Lade Kommentare">
|
||||
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
|
||||
{#each Array(2) as _, i (i)}
|
||||
<li class="flex gap-3 list-none!">
|
||||
<div class="size-9 shrink-0 rounded-full bg-zinc-200 animate-pulse"></div>
|
||||
<div class="size-9 shrink-0 rounded-full bg-stein-200 animate-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3 w-32 bg-zinc-200 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-full bg-zinc-200 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-200 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-32 bg-stein-200 rounded-xs animate-pulse"></div>
|
||||
<div class="h-3 w-full bg-stein-200 rounded-xs animate-pulse"></div>
|
||||
<div class="h-3 w-3/4 bg-stein-200 rounded-xs animate-pulse"></div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if loadError}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
|
||||
<div class="flex items-start gap-2 rounded-lg border border-fire-200 bg-fire-50 p-3 text-sm text-fire-800">
|
||||
<Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">Kommentare konnten nicht geladen werden.</p>
|
||||
<p class="mt-1 text-xs text-red-700">{loadError}</p>
|
||||
<p class="font-medium">{t(T.comments_load_error_title)}</p>
|
||||
<p class="mt-1 text-xs text-fire-700">{loadError}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={loadComments}
|
||||
class="text-xs text-red-800 underline hover:no-underline"
|
||||
>Erneut versuchen</button>
|
||||
class="text-xs text-fire-800 underline hover:no-underline"
|
||||
>{t(T.comments_retry)}</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if topLevel.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300" />
|
||||
<p class="text-sm text-zinc-500">Noch keine Kommentare. Sei der erste.</p>
|
||||
<div class="flex flex-col items-center gap-1 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-1.5">
|
||||
<Icon icon="lucide:message-square-text" class="size-5 text-stein-300 shrink-0" />
|
||||
<p class="text-sm font-semibold text-stein-300">{t(T.comments_empty)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-5 mb-8 list-none p-0">
|
||||
@@ -407,7 +410,7 @@
|
||||
{@const replies = repliesOf(c.id)}
|
||||
<li
|
||||
class="group flex gap-3 list-none! {c.status === 'pending'
|
||||
? 'rounded-md border border-amber-200 bg-amber-50/50 p-3'
|
||||
? 'rounded-lg border border-erde-200 bg-erde-50/50 p-3'
|
||||
: ''}"
|
||||
>
|
||||
{@render avatar(c.author)}
|
||||
@@ -419,55 +422,59 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
|
||||
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
||||
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:reply" class="size-3" />
|
||||
<Icon icon="lucide:reply" class="size-3" />
|
||||
{replyTo === c.id
|
||||
? "Abbrechen"
|
||||
: replies.length > 0
|
||||
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}`
|
||||
: "Antworten"}
|
||||
? t(T.comments_cancel)
|
||||
: replies.length === 0
|
||||
? t(T.comments_reply)
|
||||
: replies.length === 1
|
||||
? t(T.comments_reply_count_one)
|
||||
: t(T.comments_reply_count_other, { count: replies.length })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if replyTo === c.id}
|
||||
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
<TextInput
|
||||
bind:value={replyAuthor}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t(T.comments_name_placeholder)}
|
||||
label=""
|
||||
required
|
||||
maxlength="80"
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
size="sm"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={replyBody}
|
||||
use:autosize
|
||||
onkeydown={submitOnCmdEnter}
|
||||
placeholder="Deine Antwort"
|
||||
placeholder={t(T.comments_reply_placeholder)}
|
||||
required
|
||||
rows="2"
|
||||
maxlength={bodyMax}
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
||||
></textarea>
|
||||
{#if replyError}<p class="text-red-700 text-xs">{replyError}</p>{/if}
|
||||
{#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
|
||||
>Antwort senden</button>
|
||||
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
label={t(T.comments_reply_send)}
|
||||
class="text-sm"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-stein-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if replies.length > 0}
|
||||
<ul class="mt-4 space-y-4 list-none p-0 border-l border-zinc-200 pl-4">
|
||||
<ul class="mt-4 space-y-4 list-none p-0 border-l border-stein-200 pl-4">
|
||||
{#each replies as r (r.id)}
|
||||
<li
|
||||
class="group flex gap-3 list-none! {r.status === 'pending'
|
||||
? 'rounded-md border border-amber-200 bg-amber-50/50 p-2'
|
||||
? 'rounded-lg border border-erde-200 bg-erde-50/50 p-2'
|
||||
: ''}"
|
||||
>
|
||||
{@render avatar(r.author, "sm")}
|
||||
@@ -485,25 +492,24 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
|
||||
<h3 class="font-medium text-sm text-zinc-900">Kommentar schreiben</h3>
|
||||
<input
|
||||
type="text"
|
||||
<form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3">
|
||||
<h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3>
|
||||
<TextInput
|
||||
bind:value={author}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t(T.comments_name_placeholder)}
|
||||
label=""
|
||||
required
|
||||
maxlength="80"
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
size="sm"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={body}
|
||||
use:autosize
|
||||
onkeydown={submitOnCmdEnter}
|
||||
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet."
|
||||
placeholder={t(T.comments_body_placeholder)}
|
||||
required
|
||||
rows="3"
|
||||
maxlength={bodyMax}
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
||||
></textarea>
|
||||
<input
|
||||
type="text"
|
||||
@@ -514,27 +520,30 @@
|
||||
class="absolute left-[-9999px]"
|
||||
/>
|
||||
{#if submitError}
|
||||
<p class="text-red-700 text-xs">{submitError}</p>
|
||||
<p class="text-fire-700 text-xs">{submitError}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !body.trim() || !author.trim()}
|
||||
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "Sende …" : "Senden"}
|
||||
</button>
|
||||
variant="primary"
|
||||
loading={submitting}
|
||||
disabled={!body.trim() || !author.trim()}
|
||||
label={submitting ? t(T.comments_sending) : t(T.comments_send)}
|
||||
class="text-sm"
|
||||
/>
|
||||
{#if requireApproval}
|
||||
<span class="text-zinc-500 inline-flex items-center gap-1">
|
||||
<Icon icon="mdi:shield-check-outline" class="size-3.5" />
|
||||
Wird vor Veröffentlichung geprüft.
|
||||
<span class="text-stein-500 inline-flex items-center gap-1">
|
||||
<Icon icon="lucide:shield-check" class="size-3.5" />
|
||||
{t(T.comments_approval_hint)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span>
|
||||
<span class="ml-auto text-stein-400 tabular-nums">{body.length} / {bodyMax}</span>
|
||||
</div>
|
||||
{#if totalPending > 0}
|
||||
<p class="text-xs text-zinc-500 italic">
|
||||
{totalPending} {totalPending === 1 ? 'Kommentar wartet' : 'Kommentare warten'} noch auf Freigabe (nur für dich sichtbar).
|
||||
<p class="text-xs text-stein-500 italic">
|
||||
{totalPending === 1
|
||||
? t(T.comments_pending_summary_one)
|
||||
: t(T.comments_pending_summary_other, { count: totalPending })}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import ContactCard from './ContactCard.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/ContactCard',
|
||||
component: ContactCard,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ContactCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
contact: {
|
||||
name: 'Maria Müller',
|
||||
role: 'Sprecherin',
|
||||
email: 'maria@example.org',
|
||||
phone: '01234 567890',
|
||||
note: 'Erreichbar Mo–Fr',
|
||||
organisations: [{ name: 'BI Steigerwald' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: { contact: { name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' } },
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import type { ContactEntry } from "$lib/block-types";
|
||||
import { downloadVCard } from "$lib/vcard";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
|
||||
let { contact }: { contact: ContactEntry } = $props();
|
||||
|
||||
const orgNames = $derived(
|
||||
(contact.organisations ?? [])
|
||||
.filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && o !== null)
|
||||
.map((o) => o.name)
|
||||
.filter(Boolean) as string[]
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="not-prose card-surface card-hover-lift relative flex h-full min-w-0 flex-col gap-2 overflow-hidden p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
|
||||
|
||||
{#if contact.role || orgNames.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if contact.role}
|
||||
<Badge>{contact.role}</Badge>
|
||||
{/if}
|
||||
{#each orgNames as org}
|
||||
<Badge color="green">{org}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- vCard download -->
|
||||
<button
|
||||
type="button"
|
||||
title="Kontakt speichern (.vcf)"
|
||||
onclick={() => downloadVCard(contact)}
|
||||
class="absolute top-3 right-3 text-stein-300 transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:id-card" class="size-4" />
|
||||
</button>
|
||||
|
||||
{#if contact.phone || contact.email}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if contact.phone}
|
||||
<a href="tel:{contact.phone.replace(/\s/g, '')}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
|
||||
<Icon icon="lucide:phone" class="size-3.5 shrink-0 text-stein-400" />
|
||||
<span class="truncate">{contact.phone}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a href="mailto:{contact.email}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
|
||||
<Icon icon="lucide:mail" class="size-3.5 shrink-0 text-stein-400" />
|
||||
<span class="truncate">{contact.email}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contact.note}
|
||||
<p class="text-[11px] text-stein-400">{contact.note}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import BlockRenderer from "./BlockRenderer.svelte";
|
||||
import type { Translations } from "$lib/translations";
|
||||
import type { ResolvedBlock } from "$lib/block-types";
|
||||
import {
|
||||
selectModal,
|
||||
dismissKey,
|
||||
type ModalLike,
|
||||
} from "$lib/modal-match";
|
||||
|
||||
interface ModalEntry extends ModalLike {
|
||||
trigger?: "immediate" | "delay" | "scroll" | "exit_intent";
|
||||
delaySeconds?: number;
|
||||
scrollPercent?: number;
|
||||
content?: unknown[];
|
||||
dismissible?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
let {
|
||||
modals = [],
|
||||
pathname,
|
||||
translations = {},
|
||||
}: {
|
||||
modals?: ModalEntry[];
|
||||
pathname: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
let active = $state<ModalEntry | null>(null);
|
||||
let open = $state(false);
|
||||
let dontShowAgain = $state(false);
|
||||
|
||||
/** Nur diese Session ausgeblendet (X/Escape/Backdrop). */
|
||||
function sessionKey(m: ModalEntry): string {
|
||||
return `modal-session:${m.slug ?? m._slug ?? "unknown"}:${m.version ?? ""}`;
|
||||
}
|
||||
|
||||
function isDismissed(m: ModalEntry): boolean {
|
||||
try {
|
||||
if (localStorage.getItem(dismissKey(m))) return true;
|
||||
if (sessionStorage.getItem(sessionKey(m))) return true;
|
||||
} catch {
|
||||
/* Storage gesperrt (Private Mode) → nicht als dismissed werten. */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function close(permanent: boolean) {
|
||||
const m = active;
|
||||
open = false;
|
||||
if (!m) return;
|
||||
try {
|
||||
if (permanent && m.dismissible !== false) {
|
||||
localStorage.setItem(dismissKey(m), "1");
|
||||
} else {
|
||||
sessionStorage.setItem(sessionKey(m), "1");
|
||||
}
|
||||
} catch {
|
||||
/* Storage gesperrt → Modal kommt beim nächsten Load wieder. */
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && open) close(dontShowAgain);
|
||||
}
|
||||
|
||||
const sizeClass = $derived(
|
||||
active?.size === "sm"
|
||||
? "max-w-md"
|
||||
: active?.size === "lg"
|
||||
? "max-w-3xl"
|
||||
: "max-w-xl",
|
||||
);
|
||||
|
||||
// Kandidat je Route neu wählen und Trigger scharfschalten. Cleanup läuft vor
|
||||
// jedem Re-Run (Navigation) und beim Destroy — entfernt Timer/Listener.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const path = pathname;
|
||||
const list = modals ?? [];
|
||||
|
||||
open = false;
|
||||
dontShowAgain = false;
|
||||
const m = selectModal<ModalEntry>(list, path, Date.now(), isDismissed);
|
||||
active = m;
|
||||
if (!m) return;
|
||||
|
||||
const show = () => {
|
||||
open = true;
|
||||
};
|
||||
let removeScroll: (() => void) | null = null;
|
||||
let removeExit: (() => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
switch (m.trigger ?? "delay") {
|
||||
case "immediate":
|
||||
show();
|
||||
break;
|
||||
case "delay":
|
||||
timer = setTimeout(show, Math.max(0, m.delaySeconds ?? 3) * 1000);
|
||||
break;
|
||||
case "scroll": {
|
||||
const pct = Math.min(100, Math.max(1, m.scrollPercent ?? 30));
|
||||
const onScroll = () => {
|
||||
const h =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scrolled = h > 0 ? (window.scrollY / h) * 100 : 100;
|
||||
if (scrolled >= pct) {
|
||||
show();
|
||||
removeScroll?.();
|
||||
removeScroll = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
removeScroll = () =>
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
onScroll(); // Fall: Seite kürzer als Trigger → sofort
|
||||
break;
|
||||
}
|
||||
case "exit_intent": {
|
||||
// Nur Desktop (feiner Zeiger) — Touch hat kein Exit-Intent.
|
||||
if (window.matchMedia("(pointer: fine)").matches) {
|
||||
const onOut = (e: MouseEvent) => {
|
||||
if (e.clientY <= 0) show();
|
||||
};
|
||||
document.addEventListener("mouseout", onOut);
|
||||
removeExit = () =>
|
||||
document.removeEventListener("mouseout", onOut);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
removeScroll?.();
|
||||
removeExit?.();
|
||||
};
|
||||
});
|
||||
|
||||
const blocks = $derived((active?.content ?? []) as ResolvedBlock[]);
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeydown} />
|
||||
|
||||
{#if open && active}
|
||||
<div
|
||||
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
onclick={() => close(dontShowAgain)}
|
||||
>
|
||||
<div
|
||||
class="relative w-full {sizeClass} max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-2xl md:p-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => close(dontShowAgain)}
|
||||
class="absolute top-3 right-3 rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<Icon icon="lucide:x" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="content-modal-body">
|
||||
{#each blocks as block (block)}
|
||||
<BlockRenderer {block} {translations} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if active.dismissible !== false}
|
||||
<div class="mt-6 flex items-center justify-between gap-4 border-t border-gray-200 pt-4">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 accent-wald-700"
|
||||
bind:checked={dontShowAgain}
|
||||
/>
|
||||
<span>Dauerhaft nicht mehr anzeigen</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-wald-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wald-800"
|
||||
onclick={() => close(dontShowAgain)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,45 +1,9 @@
|
||||
<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 FilesBlock from "./blocks/FilesBlock.svelte";
|
||||
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
||||
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
||||
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.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 DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||
import BlockRenderer from "./BlockRenderer.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,
|
||||
FilesBlockData,
|
||||
YoutubeVideoBlockData,
|
||||
QuoteBlockData,
|
||||
QuoteCarouselBlockData,
|
||||
LinkListBlockData,
|
||||
PostOverviewBlockData,
|
||||
SearchableTextBlockData,
|
||||
CalendarBlockData,
|
||||
OrganisationsBlockData,
|
||||
OpnFormBlockData,
|
||||
DeadlineBannerBlockData,
|
||||
} from "$lib/block-types";
|
||||
import type { RowContentLayout, ResolvedBlock } from "$lib/block-types";
|
||||
|
||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||
|
||||
@@ -101,66 +65,25 @@
|
||||
>
|
||||
{#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) === "files"}
|
||||
<FilesBlock block={item as FilesBlockData} />
|
||||
{: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) === "quote_carousel"}
|
||||
<QuoteCarouselBlock block={item as QuoteCarouselBlockData} />
|
||||
{: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 if blockType(item) === "deadline_banner"}
|
||||
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
|
||||
{: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)}
|
||||
{@const type = 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'
|
||||
class="component-breakout--{type} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {type === 'quote'
|
||||
? 'bg-wald-50'
|
||||
: '[background:var(--color-container-breakout)]'}"
|
||||
>
|
||||
{#if rowBlockType === 'deadline_banner'}
|
||||
{@render blockContent()}
|
||||
{#if type === 'deadline_banner'}
|
||||
<BlockRenderer block={item} {translations} />
|
||||
{:else}
|
||||
<div class="container-custom py-8">
|
||||
{@render blockContent()}
|
||||
<BlockRenderer block={item} {translations} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render blockContent()}
|
||||
<BlockRenderer block={item} {translations} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import EventBadges from './EventBadges.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/EventBadges',
|
||||
component: EventBadges,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof EventBadges>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const MitOrt: Story = {
|
||||
args: {
|
||||
eventDate: '2026-08-15T18:00:00Z',
|
||||
locationText: 'Rathaus Schleusingen',
|
||||
locationHref: 'https://www.openstreetmap.org',
|
||||
},
|
||||
};
|
||||
|
||||
export const NurDatum: Story = {
|
||||
args: { eventDate: '2026-09-05T19:00:00Z' },
|
||||
};
|
||||
@@ -27,21 +27,21 @@
|
||||
{#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" />
|
||||
<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 no-underline! [text-decoration:none]">
|
||||
<Icon icon="lucide: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 href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-stein-700 transition-colors no-underline!">
|
||||
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||
<span class="truncate max-w-[16rem]">{locationText}</span>
|
||||
</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 title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50 px-3 py-1 text-[.65rem] font-medium shadow-sm no-underline! [text-decoration:none]">
|
||||
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||
<span class="truncate max-w-[16rem]">{locationText}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import EventMap from './EventMap.svelte';
|
||||
|
||||
// Karten-Embed für Termine (iframe + Link). Tiles laden extern bei Netzzugriff.
|
||||
const meta = {
|
||||
title: 'Molecules/EventMap',
|
||||
component: EventMap,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof EventMap>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
embedUrl:
|
||||
'https://www.openstreetmap.org/export/embed.html?bbox=10.6,50.4,10.9,50.6&layer=mapnik',
|
||||
linkUrl: 'https://www.openstreetmap.org/#map=13/50.51/10.75',
|
||||
locationText: 'Rathaus Schleusingen',
|
||||
label: 'Veranstaltungsort',
|
||||
},
|
||||
};
|
||||
@@ -44,8 +44,8 @@
|
||||
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" />
|
||||
<div class="flex items-center gap-2 rounded-full bg-white/90 px-4 py-2 text-xs font-medium text-stein-700 shadow">
|
||||
<Icon icon="lucide:mouse-pointer-click" class="size-4 shrink-0" aria-hidden="true" />
|
||||
{t(translations, T.post_map_activate)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
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" />
|
||||
<Icon icon="lucide:external-link" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t(translations, T.post_map_open_osm)}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -70,7 +70,7 @@
|
||||
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" />
|
||||
<Icon icon="lucide:map-pin" class="size-4 shrink-0" aria-hidden="true" />
|
||||
{locationText ?? t(translations, T.post_map_open_osm)}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -8,12 +8,26 @@
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
|
||||
let {
|
||||
footer = null,
|
||||
translations = {},
|
||||
logoSvgHtml = null,
|
||||
logoUrl = null,
|
||||
siteName = "",
|
||||
}: {
|
||||
footer?: FooterEntry | null;
|
||||
translations?: Translations | null;
|
||||
logoSvgHtml?: string | null;
|
||||
logoUrl?: string | null;
|
||||
siteName?: string;
|
||||
} = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const hasRowContent = $derived(
|
||||
!!footer &&
|
||||
((footer.row1Content?.length ?? 0) > 0 ||
|
||||
@@ -24,21 +38,37 @@
|
||||
|
||||
{#if footer}
|
||||
<footer
|
||||
class="mt-auto bg-zinc-950 text-white py-10"
|
||||
class="mt-auto bg-stein-900 text-stein-0 py-10"
|
||||
data-footer-id={footer.id}
|
||||
>
|
||||
<div class="container-custom">
|
||||
{#if hasRowContent}
|
||||
<div class="content-footer text-sm text-zinc-200">
|
||||
<div class="content-footer text-sm text-stein-200">
|
||||
<ContentRows layout={footer} translations={translations} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-white/80 text-center">
|
||||
{/if}
|
||||
|
||||
<!-- Bottom-Bar: Brand links, Copyright/Lizenz rechts (immer, unabhängig vom CMS-Content). -->
|
||||
<div
|
||||
class="mt-8 flex flex-col gap-4 border-t border-stein-700 pt-6 text-xs text-stein-400 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 text-stein-0">
|
||||
{#if logoSvgHtml}
|
||||
<span
|
||||
class="footer-logo inline-flex h-6 w-auto items-center [&_svg]:block [&_svg]:h-full [&_svg]:w-auto"
|
||||
aria-hidden="true">{@html logoSvgHtml}</span
|
||||
>
|
||||
{:else if logoUrl && logoUrl.trim() !== ""}
|
||||
<img src={logoUrl} alt="" class="h-6 w-auto object-contain" />
|
||||
{/if}
|
||||
<span class="font-bold">{siteName}</span>
|
||||
</div>
|
||||
<p class="md:text-right">
|
||||
{t(T.footer_copyright) !== T.footer_copyright
|
||||
? t(T.footer_copyright)
|
||||
: `© ${new Date().getFullYear()} Windwiderstand`}
|
||||
: `Copyright © ${year} · Inhalte frei nutzbar mit Quellenangabe „windwiderstand.de“ · CC BY 4.0`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
@@ -47,33 +77,58 @@
|
||||
.content-footer :global(.content) {
|
||||
margin: 0;
|
||||
}
|
||||
.content-footer :global(.content-row) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.content-footer :global(.content-row > *) {
|
||||
flex: 1 1 200px;
|
||||
grid-column: auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Footer nutzt jetzt das normale 12-Spalten-Grid (grid grid-cols-12 aus
|
||||
ContentRows) + per-Block layout (mobile/tablet/desktop + desktopStart).
|
||||
Kein Flex-Override mehr — Spaltigkeit kommt aus dem CMS-layout. */
|
||||
.content-footer :global(.content-row + .content-row) {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgb(63 63 70);
|
||||
border-top: 1px solid var(--color-stein-700);
|
||||
}
|
||||
/* Globaler Row-Trenner (::before aus app.css) doppelt den eigenen
|
||||
border-top-Trenner → hier abschalten. */
|
||||
.content-footer :global(.content-row + .content-row::before) {
|
||||
display: none;
|
||||
}
|
||||
/* Trailing-Margins im Footer killen: letzter Block (Wrapper mb-4) und
|
||||
letzter Absatz (.markdown p mb-4) sonst → Leerraum unter Copyright/Lizenz
|
||||
vor dem Footer-Rand (zusätzlich zum py-10-Padding). */
|
||||
.content-footer :global(.content-row:last-child > *:last-child),
|
||||
.content-footer :global(.markdown > p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.content-footer :global(a) {
|
||||
color: rgb(228 228 231);
|
||||
color: var(--color-stein-200);
|
||||
}
|
||||
/* Markdown-Links (z. B. „Mehr dazu" in Copyright-Zeile) unterstreichen –
|
||||
konsistent mit den Link-Listen, die schon Underline haben. */
|
||||
.content-footer :global(.markdown a) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.content-footer :global(a:hover) {
|
||||
color: rgb(255 255 255);
|
||||
color: var(--color-stein-0);
|
||||
}
|
||||
.content-footer :global(h3) {
|
||||
color: rgb(250 250 250);
|
||||
color: var(--color-stein-0);
|
||||
}
|
||||
/* Link-Listen-Überschriften ("Seiten", "Kontakt & Community") als kleine
|
||||
Label-Kappen — überschreibt die unlayered h3-Defaults (daher !important). */
|
||||
.content-footer :global(.link-list h3) {
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.2 !important;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-stein-400) !important;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
/* Footer-Navigationslinks ohne Unterstrich (Markdown-Links bleiben unterstrichen). */
|
||||
.content-footer :global(.link-list a) {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.content-footer :global(.markdown),
|
||||
.content-footer :global(.prose) {
|
||||
color: rgb(212 212 216);
|
||||
color: var(--color-stein-200);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Header from './Header.svelte';
|
||||
|
||||
// Nutzt den SvelteKit-page-Store (aktiver Link) — vom sveltekit-Framework
|
||||
// gemockt. Logo als Inline-SVG-Platzhalter.
|
||||
const meta = {
|
||||
title: 'Organisms/Header',
|
||||
component: Header,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
} satisfies Meta<typeof Header>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
logoSvgHtml: '<span style="font-weight:700;font-size:1.25rem">Windwiderstand</span>',
|
||||
links: [
|
||||
{ href: '/', label: 'Start' },
|
||||
{
|
||||
href: '/verfahren',
|
||||
label: 'Verfahren',
|
||||
children: [
|
||||
{ href: '/verfahren/ablauf', label: 'Ablauf' },
|
||||
{ href: '/verfahren/fristen', label: 'Fristen' },
|
||||
],
|
||||
},
|
||||
{ href: '/posts', label: 'Aktuelles' },
|
||||
{ href: '/mitmachen', label: 'Mitmachen' },
|
||||
],
|
||||
socialLinks: [
|
||||
{ href: 'https://example.org', icon: 'mdi:instagram', label: 'Instagram' },
|
||||
{ href: 'https://example.org', icon: 'mdi:youtube', label: 'YouTube' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,30 @@
|
||||
<script lang="ts">
|
||||
import '$lib/iconify-offline';
|
||||
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";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
function isActiveLink(href: string, pathname: string): boolean {
|
||||
if (!href) return false;
|
||||
const h = href.replace(/\/$/, "") || "/";
|
||||
const p = pathname.replace(/\/$/, "") || "/";
|
||||
if (h === "/") return p === "/";
|
||||
return p === h || p.startsWith(h + "/");
|
||||
}
|
||||
|
||||
interface NavLink {
|
||||
href: string;
|
||||
label: string;
|
||||
children?: NavLink[];
|
||||
}
|
||||
|
||||
function isActiveTree(link: NavLink, pathname: string): boolean {
|
||||
if (isActiveLink(link.href, pathname)) return true;
|
||||
return (link.children ?? []).some((c) => isActiveLink(c.href, pathname));
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
@@ -37,6 +52,11 @@
|
||||
}
|
||||
let menuOpen = $state(false);
|
||||
let searchOpen = $state(false);
|
||||
let openSubmenu = $state<string | null>(null);
|
||||
|
||||
function toggleSubmenu(label: string) {
|
||||
openSubmenu = openSubmenu === label ? null : label;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (menuOpen) {
|
||||
@@ -44,6 +64,11 @@
|
||||
} else {
|
||||
searchOpen = false;
|
||||
menuOpen = true;
|
||||
// Sektion der aktuellen Seite direkt aufgeklappt zeigen.
|
||||
openSubmenu =
|
||||
links.find(
|
||||
(l) => l.children && l.children.length > 0 && isActiveTree(l, $page.url.pathname),
|
||||
)?.label ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +116,22 @@
|
||||
|
||||
<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%"
|
||||
class="relative sticky top-0 z-20 border-b border-white/30 shadow-2xl bg-wald-50"
|
||||
>
|
||||
<div class="flex items-center justify-between container-custom py-2">
|
||||
<span class="gradient-blob-bg-inner" aria-hidden="true"></span>
|
||||
<div class="relative flex items-center justify-between container-custom py-2">
|
||||
<a
|
||||
href="/"
|
||||
aria-label={t(T.site_name_fallback)}
|
||||
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>
|
||||
<span
|
||||
class="logo-svg h-8 lg:h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-8 lg:[&_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" />
|
||||
<img src={logoUrl} alt="" class="h-8 lg:h-10 w-auto object-contain" />
|
||||
{:else}
|
||||
<span>{t(T.site_name_fallback)}</span>
|
||||
{/if}
|
||||
@@ -114,48 +143,111 @@
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
{#each links as link}
|
||||
{@const active = isActiveTree(link, $page.url.pathname)}
|
||||
{#if link.children && link.children.length > 0}
|
||||
<div class="relative group inline-flex items-center">
|
||||
{#if link.href && link.href !== "#"}
|
||||
<a
|
||||
href={link.href}
|
||||
class="hover:animate-pulse transition-all text-xs tracking-wide"
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{link.label}
|
||||
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{link.label}
|
||||
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute left-0 top-full pt-1 min-w-[10rem] opacity-0 invisible translate-y-1 transition-all group-hover:opacity-100 group-hover:visible group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 z-50"
|
||||
>
|
||||
<ul class="bg-stein-0 shadow-lg rounded border border-stein-200 py-1 text-stein-800">
|
||||
{#each link.children as child}
|
||||
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
|
||||
<li role="none">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={child.href}
|
||||
class="block px-3 py-1.5 text-xs hover:bg-wald-50 {cActive ? 'font-bold text-wald-700' : ''}"
|
||||
aria-current={cActive ? "page" : undefined}
|
||||
>
|
||||
{child.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-[.7rem] leading-none py-1 inline-flex items-center {active ? 'font-bold ' : ''}"
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="w-1 h-4"></div>
|
||||
<div class="ml-2 flex items-center gap-1.5">
|
||||
{#each socialLinks as social}
|
||||
{@const isInternal = social.href.startsWith("/")}
|
||||
{@const sActive = isInternal && isActiveLink(social.href, $page.url.pathname)}
|
||||
<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"
|
||||
target={isInternal ? undefined : "_blank"}
|
||||
rel={isInternal ? undefined : "noopener noreferrer"}
|
||||
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center gap-1.5 {sActive ? 'font-bold' : ''}"
|
||||
aria-current={sActive ? "page" : undefined}
|
||||
aria-label={social.label || t(T.header_social_aria)}
|
||||
title={social.label || t(T.header_social_aria)}
|
||||
>
|
||||
<Icon icon={social.icon} aria-hidden="true" />
|
||||
<Icon icon={social.icon} class="text-sm" aria-hidden="true" />
|
||||
{#if social.label}
|
||||
<span class="text-[.7rem] leading-none">{social.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
|
||||
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
title={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" />
|
||||
<Icon
|
||||
icon="lucide:x"
|
||||
class="text-sm text-error!"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" aria-hidden="true" />
|
||||
<Icon icon="lucide:search" class="text-sm" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
@@ -165,34 +257,39 @@
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
class="text-2xl text-error!"
|
||||
icon="lucide:x"
|
||||
class="text-sm text-error!"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="text-2xl"
|
||||
icon="lucide:search"
|
||||
class="text-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
|
||||
class="-mr-1 inline-flex items-center gap-1.5 rounded-full border border-wald-600/40 bg-wald-600/25 px-2.5 py-1 text-wald-800 transition-opacity hover:opacity-70"
|
||||
aria-label={menuOpen
|
||||
? t(T.header_menu_close)
|
||||
: t(T.header_menu_open)}
|
||||
aria-controls={menuOpen ? "mobile-nav" : undefined}
|
||||
aria-expanded={menuOpen}
|
||||
onclick={toggleMenu}
|
||||
>
|
||||
<span class="sr-only"
|
||||
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</span
|
||||
>{menuOpen
|
||||
? t(T.header_menu_close)
|
||||
: t(T.header_menu_open)}</span
|
||||
>
|
||||
{#if menuOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
|
||||
<Icon icon="lucide:x" class="text-sm" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
|
||||
<Icon icon="lucide:menu" class="text-sm" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="text-[.7rem] leading-none" aria-hidden="true">{menuOpen ? "Schließen" : "Menü"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,43 +300,109 @@
|
||||
{#if menuOpen}
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
|
||||
class="lg:hidden absolute left-0 right-0 top-full z-30 shadow-2xl border-t border-stein-0/10 bg-stein-900/90 backdrop-blur-lg max-h-[calc(100dvh-3.5rem)] overflow-y-auto overscroll-contain"
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<nav
|
||||
class="container-custom py-4 flex flex-col gap-1"
|
||||
class="container-custom pt-4 pb-[calc(4rem+env(safe-area-inset-bottom))] flex flex-col gap-1"
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2.5 pb-4 mb-2 border-b border-stein-0/10">
|
||||
<a href="/mitmachen/" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-wald-400/40 px-3 py-2 text-sm font-medium text-wald-200 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
|
||||
<Icon icon="lucide:heart-handshake" class="size-4 shrink-0" aria-hidden="true" />
|
||||
Mitmachen
|
||||
</a>
|
||||
<a href="/termin-melden" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-gelb-400/40 px-3 py-2 text-sm font-medium text-gelb-300 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
|
||||
<Icon icon="lucide:calendar-plus" class="size-4 shrink-0" aria-hidden="true" />
|
||||
Termin melden
|
||||
</a>
|
||||
</div>
|
||||
{#each links as link}
|
||||
{@const active = isActiveTree(link, $page.url.pathname)}
|
||||
{#if link.children && link.children.length > 0}
|
||||
{@const expanded = openSubmenu === link.label}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between py-2.5 px-3 rounded-xs transition-colors text-sm text-stein-100 hover:bg-stein-0/10 border-l-4 {active ? 'border-wald-400 text-wald-300' : 'border-transparent'}"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => toggleSubmenu(link.label)}
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
<Icon icon={expanded ? "lucide:chevron-up" : "lucide:chevron-down"} class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="ml-6 border-l border-stein-0/10" in:slide={{ duration: 150 }} out:slide={{ duration: 100 }}>
|
||||
{#if link.href && link.href !== "#"}
|
||||
{@const pActive = isActiveLink(link.href, $page.url.pathname)}
|
||||
<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"
|
||||
class="block py-2 px-3 text-sm {pActive ? 'text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
|
||||
aria-current={pActive ? "page" : undefined}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{link.label} – Übersicht
|
||||
</a>
|
||||
{/if}
|
||||
{#each link.children as child}
|
||||
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
|
||||
<a
|
||||
href={child.href}
|
||||
class="block py-2 px-3 text-sm {cActive ? 'text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
|
||||
aria-current={cActive ? "page" : undefined}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{child.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href={link.href}
|
||||
class="block py-2.5 px-3 rounded-xs transition-colors text-sm {active
|
||||
? 'text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
|
||||
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if socialLinks.length > 0}
|
||||
<div
|
||||
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
|
||||
>
|
||||
<div class="flex flex-col gap-1 pt-2 mt-2 border-t border-stein-0/10">
|
||||
{#each socialLinks as social}
|
||||
{@const isInternal = social.href.startsWith("/")}
|
||||
{@const sActive =
|
||||
isInternal &&
|
||||
isActiveLink(social.href, $page.url.pathname)}
|
||||
<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)}
|
||||
target={isInternal ? undefined : "_blank"}
|
||||
rel={isInternal
|
||||
? undefined
|
||||
: "noopener noreferrer"}
|
||||
class="flex items-center gap-3 py-2.5 px-3 rounded-xs transition-colors text-sm border-l-4 {sActive
|
||||
? 'text-wald-300 bg-wald-700/20 border-wald-400'
|
||||
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-transparent'}"
|
||||
aria-current={sActive ? "page" : undefined}
|
||||
aria-label={social.label ||
|
||||
t(T.header_social_aria)}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<Icon
|
||||
icon={social.icon}
|
||||
class="text-[1.5rem]"
|
||||
class="text-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{#if social.label}
|
||||
<span>{social.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
args: { icon: 'lucide:chevron-right', label: 'Weiter' },
|
||||
argTypes: {
|
||||
size: { control: 'select', options: ['sm', 'md'] },
|
||||
variant: { control: 'select', options: ['surface', 'ghost'] },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof IconButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Surface: Story = { args: { variant: 'surface' } };
|
||||
export const Ghost: Story = { args: { variant: 'ghost', icon: 'lucide:x', label: 'Schließen' } };
|
||||
export const Klein: Story = { args: { variant: 'surface', size: 'sm', icon: 'lucide:chevron-left', label: 'Zurück' } };
|
||||
export const Disabled: Story = { args: { variant: 'surface', disabled: true } };
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* IconButton — runder Icon-only Button (Carousel-Pfeile, Modal-Close, …).
|
||||
* Die eine Quelle für kreisförmige Icon-Buttons. `label` = aria-label (Pflicht).
|
||||
* variant: surface (weiß, Border, Shadow — z.B. Slider-Pfeile) · ghost (transparent).
|
||||
*/
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from "svelte/elements";
|
||||
|
||||
type Size = "sm" | "md";
|
||||
type Variant = "surface" | "ghost";
|
||||
|
||||
let {
|
||||
icon,
|
||||
label,
|
||||
href = undefined,
|
||||
onclick = undefined,
|
||||
size = "md",
|
||||
variant = "surface",
|
||||
disabled = false,
|
||||
class: className = "",
|
||||
...rest
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
size?: Size;
|
||||
variant?: Variant;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
} & HTMLButtonAttributes &
|
||||
HTMLAnchorAttributes = $props();
|
||||
|
||||
const sizeClass: Record<Size, string> = { sm: "size-7", md: "size-9" };
|
||||
const iconClass: Record<Size, string> = { sm: "size-4", md: "size-5" };
|
||||
const variantClass: Record<Variant, string> = {
|
||||
surface:
|
||||
"border border-stein-200 bg-white text-stein-700 shadow-md hover:bg-stein-50",
|
||||
ghost: "text-stein-600 hover:bg-stein-100",
|
||||
};
|
||||
|
||||
const cls = $derived(
|
||||
`inline-flex shrink-0 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizeClass[size]} ${variantClass[variant]} ${className}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href && !disabled}
|
||||
<a {href} class={cls} aria-label={label} {...rest}>
|
||||
<Icon icon={icon} class={iconClass[size]} aria-hidden="true" />
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" {disabled} {onclick} class={cls} aria-label={label} {...rest}>
|
||||
<Icon icon={icon} class={iconClass[size]} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import IconButton from "$lib/components/IconButton.svelte";
|
||||
|
||||
let {
|
||||
src,
|
||||
alt = "",
|
||||
onclose,
|
||||
}: { src: string; alt?: string; onclose: () => void } = $props();
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
|
||||
onMount(() => window.addEventListener("keydown", onKeydown));
|
||||
onDestroy(() => {
|
||||
if (typeof window !== "undefined")
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 p-4 cursor-zoom-out"
|
||||
onclick={onclose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt || "Bild Vollansicht"}
|
||||
>
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<IconButton icon="lucide:x" label="Schließen" variant="ghost" size="md" onclick={onclose} class="absolute top-4 right-4" />
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import InfoAccordion from './InfoAccordion.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/InfoAccordion',
|
||||
component: InfoAccordion,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
title: 'Wie läuft das Verfahren ab?',
|
||||
body: 'Der Regionalplan wird ausgelegt, Einwendungen sind bis zum Fristende möglich.',
|
||||
},
|
||||
} satisfies Meta<typeof InfoAccordion>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Geschlossen: Story = {};
|
||||
export const Offen: Story = { args: { open: true } };
|
||||
export const Warnung: Story = {
|
||||
args: { variant: 'amber', iconName: 'lucide:alert-triangle', title: 'Frist endet bald', open: true },
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
Reusable disclosure box for short collapsible info notes.
|
||||
- `title` — always-visible summary line
|
||||
- `body` — HTML rendered inside the panel ({@html})
|
||||
- `variant` — colour palette without callers passing raw Tailwind:
|
||||
stein → neutral data-disclaimer tone
|
||||
amber → soft warning, used for uncertainty-flagged hints
|
||||
- `open` — initial state; default closed
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
type Variant = "stein" | "amber";
|
||||
|
||||
let {
|
||||
title,
|
||||
body = "",
|
||||
variant = "stein",
|
||||
open = false,
|
||||
iconName = "lucide:info",
|
||||
}: {
|
||||
title: string;
|
||||
body?: string;
|
||||
variant?: Variant;
|
||||
open?: boolean;
|
||||
iconName?: string;
|
||||
} = $props();
|
||||
|
||||
const palettes = {
|
||||
stein: {
|
||||
container: "border-stein-200 bg-stein-50",
|
||||
summary: "text-stein-700 hover:bg-stein-100",
|
||||
icon: "text-stein-500",
|
||||
body: "border-stein-200 text-stein-600",
|
||||
},
|
||||
amber: {
|
||||
container: "border-amber-300 bg-amber-100/70",
|
||||
summary: "text-amber-800 hover:bg-amber-200/70",
|
||||
icon: "text-amber-700",
|
||||
body: "border-amber-200 text-stein-700",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const p = $derived(palettes[variant]);
|
||||
</script>
|
||||
|
||||
<details class="info-accordion rounded-md border text-xs {p.container}" {open}>
|
||||
<summary class="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 font-medium leading-snug list-none {p.summary}">
|
||||
<Icon icon={iconName} class="size-3.5 shrink-0 {p.icon}" />
|
||||
<span class="flex-1">{title}</span>
|
||||
<Icon icon="lucide:chevron-down" class="info-accordion-chevron size-3.5 shrink-0 transition-transform {p.icon}" />
|
||||
</summary>
|
||||
<div class="markdown prose prose-sm prose-zinc max-w-none border-t px-3 py-2 leading-relaxed {p.body}">{@html body}</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.info-accordion[open] :global(.info-accordion-chevron) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import InfoCard from './InfoCard.svelte';
|
||||
|
||||
const CMS_ASSET =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/InfoCard',
|
||||
component: InfoCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
icon: { control: 'text' },
|
||||
title: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
linkLabel: { control: 'text' },
|
||||
linkHref: { control: 'text' },
|
||||
external: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof InfoCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const MitIcon: Story = {
|
||||
args: {
|
||||
icon: 'mdi:email-outline',
|
||||
title: 'E-Mail',
|
||||
description: 'Für alle Fragen und Anregungen',
|
||||
linkLabel: 'mail@windwiderstand.de',
|
||||
linkHref: 'mailto:mail@windwiderstand.de',
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternerLink: Story = {
|
||||
args: {
|
||||
icon: 'mdi:whatsapp',
|
||||
title: 'WhatsApp-Community',
|
||||
description: 'Aktuelles und Austausch vor Ort',
|
||||
linkLabel: 'Beitreten',
|
||||
linkHref: 'https://chat.whatsapp.com/xyz',
|
||||
external: true,
|
||||
},
|
||||
};
|
||||
|
||||
// icon = Bild-URL statt Iconify-String → wird als Bild gerendert.
|
||||
export const MitBild: Story = {
|
||||
args: {
|
||||
icon: CMS_ASSET,
|
||||
title: 'Unsere Cloud',
|
||||
description: 'Dokumente und Materialien zum Mitnehmen',
|
||||
linkLabel: 'cloud.windwiderstand.de',
|
||||
linkHref: 'https://cloud.windwiderstand.de',
|
||||
external: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const OhneLink: Story = {
|
||||
args: {
|
||||
icon: 'mdi:youtube',
|
||||
title: 'YouTube-Kanal',
|
||||
description: 'Aufzeichnungen und Visualisierungen',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* InfoCard — Icon/Bild + Titel + Beschreibung + optionaler Link.
|
||||
* Wiederverwendbare Kachel für Sektions-Grids (siehe SectionGrid) UND als
|
||||
* Basis für spezialisierte Karten (OrganisationCard) via Snippet-Slots.
|
||||
*
|
||||
* `icon` ist entweder ein Iconify-String ("mdi:email") ODER eine Bild-URL
|
||||
* (https://… oder /…) — wird automatisch erkannt.
|
||||
* `tone`: default (weiß) oder green (grün getönt, z.B. Aside-Boxen).
|
||||
* `compact`: kleinere Maße + Icon inline neben dem Titel (spart Höhe).
|
||||
*
|
||||
* Erweiterungspunkte (halten die Basis konsistent, ohne Besonderheiten zu verlieren):
|
||||
* - `href` → ganze Karte wird Link (Tile-Hover wie Card).
|
||||
* - `media` → ersetzt das Standard-Icon-Badge (z.B. quadratisches Logo + Fallback).
|
||||
* - `badge` → absolut oben-rechts overlay.
|
||||
* - `footer` → am unteren Rand (z.B. Social-Chips).
|
||||
*/
|
||||
import type { Snippet } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import ArrowLink from "$lib/components/ArrowLink.svelte";
|
||||
|
||||
type Tone = "default" | "green";
|
||||
|
||||
let {
|
||||
icon = undefined,
|
||||
title,
|
||||
description = undefined,
|
||||
linkLabel = undefined,
|
||||
linkHref = undefined,
|
||||
external = false,
|
||||
tone = "default",
|
||||
compact = false,
|
||||
href = undefined,
|
||||
target = undefined,
|
||||
rel = undefined,
|
||||
media = undefined,
|
||||
badge = undefined,
|
||||
footer = undefined,
|
||||
}: {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
linkLabel?: string;
|
||||
linkHref?: string;
|
||||
external?: boolean;
|
||||
tone?: Tone;
|
||||
compact?: boolean;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
media?: Snippet;
|
||||
badge?: Snippet;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const isImage = $derived(!!icon && (/^https?:\/\//.test(icon) || icon.startsWith("/")));
|
||||
const hasLink = $derived(!!linkLabel && !!linkHref);
|
||||
|
||||
const shell = $derived(
|
||||
`relative flex h-full flex-col rounded-lg border ${compact ? "gap-2 p-4" : "gap-[14px] p-[22px]"} ${
|
||||
tone === "green" ? "border-wald-100 bg-wald-50" : "border-stein-200 bg-white"
|
||||
}`,
|
||||
);
|
||||
/* Klickbare Karte: Tile-Hover identisch zu Card.svelte (variant=tile), s. card-hover-lift in app.css. */
|
||||
const linkCls =
|
||||
"card-hover-lift no-underline text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2";
|
||||
</script>
|
||||
|
||||
{#snippet iconBadge(sizeClass: string, iconSize: string)}
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center overflow-hidden rounded-full {sizeClass} {isImage
|
||||
? 'border border-stein-200 bg-white'
|
||||
: tone === 'green'
|
||||
? 'bg-white text-wald-600'
|
||||
: 'bg-wald-50 text-wald-500'}"
|
||||
>
|
||||
{#if isImage}
|
||||
<RustyImage src={icon ?? ""} width={44} aspect="1/1" fit="contain" alt="" class="size-full object-contain p-0.5" />
|
||||
{:else}
|
||||
<Icon icon={icon ?? ""} class={iconSize} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet content()}
|
||||
{#if badge}
|
||||
<div class="absolute right-2 top-2 z-10">{@render badge()}</div>
|
||||
{/if}
|
||||
{#if compact}
|
||||
<!-- Kompakt: Icon hängt links, Text-Spalte (Titel/Desc/Link) rechts bündig. -->
|
||||
<div class="flex gap-2.5">
|
||||
{#if media}
|
||||
<div class="pt-px">{@render media()}</div>
|
||||
{:else if icon}
|
||||
<div class="pt-px">{@render iconBadge("size-5", "size-3")}</div>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h3 class="text-sm! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
|
||||
{#if description}
|
||||
<p class="line-clamp-3 text-[13px] font-normal leading-[1.45] text-stein-500">{description}</p>
|
||||
{/if}
|
||||
{#if hasLink}
|
||||
<div class="pt-0.5">
|
||||
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class="pt-0.5">{@render footer()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if media}
|
||||
{@render media()}
|
||||
{:else if icon}
|
||||
{@render iconBadge("size-11", "size-[23px]")}
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
|
||||
{#if description}
|
||||
<p class="line-clamp-3 text-sm font-normal leading-[1.45] text-stein-500">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasLink}
|
||||
<div class="mt-auto pt-0.5">
|
||||
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class="mt-auto pt-1">{@render footer()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} {rel} class="{shell} {linkCls}">
|
||||
{@render content()}
|
||||
</a>
|
||||
{:else}
|
||||
<div class={shell}>
|
||||
{@render content()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import OrganisationCard from './OrganisationCard.svelte';
|
||||
import type { OrganisationEntry } from '$lib/block-types';
|
||||
|
||||
const LOGO =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const base: OrganisationEntry = {
|
||||
name: 'BI Steigerwald',
|
||||
description:
|
||||
'Bürgerinitiative gegen überdimensionierte Windkraftplanung im Steigerwald. Aktiv seit 2021.',
|
||||
link: { url: 'https://example.org' },
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/OrganisationCard',
|
||||
component: OrganisationCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
compact: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof OrganisationCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Logo aus CMS.
|
||||
export const MitLogo: Story = {
|
||||
args: { org: { ...base, logo: { src: LOGO } } },
|
||||
};
|
||||
|
||||
// Ohne Logo → Fisch-Fallback.
|
||||
export const FischFallback: Story = {
|
||||
args: { org: { ...base, logo: undefined } },
|
||||
};
|
||||
|
||||
// Badge oben rechts + Social-Chips.
|
||||
export const BadgeUndSocial: Story = {
|
||||
args: {
|
||||
org: {
|
||||
...base,
|
||||
logo: { src: LOGO },
|
||||
badge: { label: 'Neu', color: 'green' },
|
||||
whatsapp: 'https://chat.whatsapp.com/xyz',
|
||||
youtube: 'https://youtube.com/@example',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Kompakt: Logo links, kein Social, dichteres Grid.
|
||||
export const Kompakt: Story = {
|
||||
args: { org: { ...base, logo: { src: LOGO } }, compact: true },
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* OrganisationCard — Organisations-Kachel auf InfoCard-Basis.
|
||||
* Teilt Shell/Typo/Link-Hover mit InfoCard, behält aber die Besonderheiten:
|
||||
* - quadratisches Logo + Fisch-Fallback (statt rundem Icon) via media-Snippet
|
||||
* - Badge oben rechts
|
||||
* - Social-Chips (WhatsApp/YouTube) im footer
|
||||
* - ganze Karte klickbar (org.link)
|
||||
* `compact` = engere Variante (Logo links, keine Social-Chips) für dichte Grids.
|
||||
*/
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import InfoCard from "$lib/components/InfoCard.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import { extractCmsImageField } from "$lib/rusty-image";
|
||||
import type { OrganisationEntry } from "$lib/block-types";
|
||||
|
||||
let {
|
||||
org,
|
||||
compact = false,
|
||||
index = 0,
|
||||
}: { org: OrganisationEntry; compact?: boolean; index?: number } = $props();
|
||||
|
||||
const logoField = $derived(extractCmsImageField(org.logo));
|
||||
const alt = $derived(
|
||||
org.logo && typeof org.logo === "object" && org.logo.description ? org.logo.description : (org.name ?? ""),
|
||||
);
|
||||
const badge = $derived(org.badge && typeof org.badge === "object" ? org.badge : null);
|
||||
const href = $derived(
|
||||
typeof org.link === "object" && org.link !== null && typeof org.link.url === "string" && org.link.url.trim() !== ""
|
||||
? org.link.url.trim()
|
||||
: undefined,
|
||||
);
|
||||
const hasSocial = $derived(!compact && (!!org.whatsapp || !!org.youtube));
|
||||
const fishMaskId = $derived(`org-logo-fallback-${index}`);
|
||||
</script>
|
||||
|
||||
{#snippet media()}
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center overflow-hidden rounded-sm border border-stein-100 bg-stein-100 {compact
|
||||
? 'h-10 w-10'
|
||||
: 'h-12 w-12'}"
|
||||
>
|
||||
{#if logoField}
|
||||
<RustyImage
|
||||
src={logoField.url}
|
||||
focal={logoField.focal ?? null}
|
||||
width={96}
|
||||
aspect="1/1"
|
||||
fit="contain"
|
||||
quality={85}
|
||||
{alt}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<svg
|
||||
class="text-stein-300 {compact ? 'h-7 w-7' : 'h-8 w-8'}"
|
||||
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>
|
||||
{/snippet}
|
||||
|
||||
{#snippet badgeSlot()}
|
||||
<Badge color={badge?.color as never}>{badge?.label}</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex gap-1.5">
|
||||
{#if org.whatsapp}
|
||||
<a
|
||||
href={org.whatsapp}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="WhatsApp"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-green-300 hover:bg-green-50 hover:text-green-700"
|
||||
>
|
||||
<Icon icon="mdi:whatsapp" class="size-3 shrink-0" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if org.youtube}
|
||||
<a
|
||||
href={org.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="YouTube"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Icon icon="lucide:youtube" class="size-3 shrink-0" />
|
||||
<span>YouTube</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<InfoCard
|
||||
{href}
|
||||
title={org.name ?? ""}
|
||||
description={org.description}
|
||||
{compact}
|
||||
{media}
|
||||
badge={badge?.label ? badgeSlot : undefined}
|
||||
footer={hasSocial ? footer : undefined}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Pagination from './Pagination.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/Pagination',
|
||||
component: Pagination,
|
||||
tags: ['autodocs'],
|
||||
args: { currentPage: 2, totalPages: 5, basePath: '/posts' },
|
||||
} satisfies Meta<typeof Pagination>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Mitte: Story = {};
|
||||
export const ErsteSeite: Story = { args: { currentPage: 1 } };
|
||||
export const LetzteSeite: Story = { args: { currentPage: 5 } };
|
||||
export const Viele: Story = { args: { currentPage: 7, totalPages: 20 } };
|
||||
@@ -11,16 +11,20 @@
|
||||
totalPages = 1,
|
||||
basePath = "/posts",
|
||||
activeTag = null,
|
||||
sort = "newest",
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
basePath?: string;
|
||||
activeTag?: string | null;
|
||||
sort?: "newest" | "oldest";
|
||||
} = $props();
|
||||
|
||||
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
|
||||
|
||||
function tagHref(tagSlug: string | null): string {
|
||||
if (!tagSlug) return `${basePath}/`;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`;
|
||||
if (!tagSlug) return `${basePath}/${query}`;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
|
||||
}
|
||||
|
||||
function pageHref(page: number): string {
|
||||
@@ -28,7 +32,7 @@
|
||||
const tagPart = activeTag
|
||||
? `/tag/${encodeURIComponent(activeTag)}`
|
||||
: "";
|
||||
return `${basePath}${tagPart}/page/${page}/`;
|
||||
return `${basePath}${tagPart}/page/${page}/${query}`;
|
||||
}
|
||||
|
||||
const showPagination = $derived(totalPages > 1);
|
||||
@@ -45,32 +49,40 @@
|
||||
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";
|
||||
const baseShape =
|
||||
"rounded-full h-8 mx-[3px] flex justify-center items-center no-underline text-xs font-medium border transition-colors";
|
||||
const inactiveColors =
|
||||
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
|
||||
const activeColors =
|
||||
"text-white border-wald-500 bg-wald-500 hover:bg-wald-600 hover:border-wald-600 pointer-events-none";
|
||||
const numberShape = "min-w-8 px-2";
|
||||
const navShape = "px-3 gap-1";
|
||||
</script>
|
||||
|
||||
{#if showPagination}
|
||||
<div class="inline-flex py-4">
|
||||
<div class="flex">
|
||||
<nav class="inline-flex py-4 items-center" aria-label="Seitennavigation">
|
||||
<div class="flex items-center">
|
||||
{#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 href={pageHref(prevPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_prev)}>
|
||||
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">{t(T.pagination_prev)}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#each pageNumbers as num}
|
||||
<a
|
||||
href={pageHref(num)}
|
||||
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
|
||||
class="{baseShape} {numberShape} {num === currentPage ? activeColors : inactiveColors}"
|
||||
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 href={pageHref(nextPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_next)}>
|
||||
<span class="hidden sm:inline">{t(T.pagination_next)}</span>
|
||||
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import QrButton from "$lib/components/QrButton.svelte";
|
||||
|
||||
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||
|
||||
let {
|
||||
url,
|
||||
title,
|
||||
readingTimeMinutes = null,
|
||||
commentPageId = null,
|
||||
commentEnvironment,
|
||||
commentTargetId = "comments-section",
|
||||
socialSlug = null,
|
||||
class: cls = "",
|
||||
}: {
|
||||
/** Absolute URL of the page being shared. */
|
||||
url: string;
|
||||
/** Page headline — used by the native share-sheet. */
|
||||
title: string;
|
||||
/** Estimated reading time. `null` hides the badge. */
|
||||
readingTimeMinutes?: number | null;
|
||||
/** Page id for the comment plugin. `null` hides the count badge. */
|
||||
commentPageId?: string | null;
|
||||
/** Optional space override for the comment count fetch. */
|
||||
commentEnvironment?: string;
|
||||
/** DOM id of the comment accordion (used by the count-badge click). */
|
||||
commentTargetId?: string;
|
||||
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
|
||||
socialSlug?: string | null;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let socialModalOpen = $state(false);
|
||||
|
||||
// Direkt-Link-Support: ?social=1 öffnet das Social-Bild-Modal automatisch
|
||||
onMount(() => {
|
||||
if (!socialSlug) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("social") === "1") {
|
||||
socialModalOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
||||
let commentCount = $state<number | null>(null);
|
||||
const cmsBase = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
$effect(() => {
|
||||
if (!commentPageId) {
|
||||
commentCount = null;
|
||||
return;
|
||||
}
|
||||
const fetchUrl = `${cmsBase}/api/comments/${encodeURIComponent(commentPageId)}/count${
|
||||
commentEnvironment
|
||||
? `?_environment=${encodeURIComponent(commentEnvironment)}`
|
||||
: ""
|
||||
}`;
|
||||
let cancelled = false;
|
||||
fetch(fetchUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
commentCount = typeof d?.count === "number" ? d.count : 0;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) commentCount = 0;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function onCommentClick(e: MouseEvent) {
|
||||
const el = document.getElementById(
|
||||
commentTargetId,
|
||||
) as HTMLDetailsElement | null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
el.open = true;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
let copied = $state(false);
|
||||
let shared = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let shareTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Share button only renders when the browser actually has a native share-
|
||||
// sheet — otherwise it would just be a duplicate of "Link kopieren".
|
||||
// SSR has no `navigator`, so this stays false until client mount.
|
||||
let canShare = $state(false);
|
||||
$effect(() => {
|
||||
canShare =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof (navigator as Navigator & { share?: unknown }).share ===
|
||||
"function";
|
||||
});
|
||||
|
||||
function flashCopied() {
|
||||
copied = true;
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function flashShared() {
|
||||
shared = true;
|
||||
if (shareTimer) clearTimeout(shareTimer);
|
||||
shareTimer = setTimeout(() => (shared = false), 1500);
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
flashCopied();
|
||||
} catch {
|
||||
// Fallback: temporary <input> + execCommand for older browsers.
|
||||
const el = document.createElement("input");
|
||||
el.value = url;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
flashCopied();
|
||||
} catch {
|
||||
// give up — clipboard not available
|
||||
} finally {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const nav = navigator as Navigator & {
|
||||
share?: (data: ShareData) => Promise<void>;
|
||||
};
|
||||
if (typeof nav.share !== "function") return;
|
||||
try {
|
||||
await nav.share({ title, url });
|
||||
flashShared();
|
||||
} catch {
|
||||
// user cancelled — no fallback, copy-link button covers that case
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ── Vorlesen (Web Speech API) ───────────────────────────────────────────
|
||||
// Button erscheint nur, wenn der Browser Sprachausgabe unterstützt.
|
||||
let canSpeak = $state(false);
|
||||
let speaking = $state(false);
|
||||
let paused = $state(false);
|
||||
$effect(() => {
|
||||
canSpeak = typeof window !== "undefined" && "speechSynthesis" in window;
|
||||
});
|
||||
|
||||
function getArticleText(): string {
|
||||
const art = document.querySelector("article");
|
||||
if (!art) return "";
|
||||
const clone = art.cloneNode(true) as HTMLElement;
|
||||
clone
|
||||
.querySelectorAll("[hidden], .sr-only, script, style, nav, figure figcaption")
|
||||
.forEach((n) => n.remove());
|
||||
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function speak() {
|
||||
const synth = window.speechSynthesis;
|
||||
synth.cancel();
|
||||
const text = getArticleText();
|
||||
if (!text) return;
|
||||
// In Sätze zerlegen — manche Engines schneiden lange Utterances ab.
|
||||
const chunks = text.match(/[^.!?]+[.!?]*(?:\s|$)/g) ?? [text];
|
||||
const voice =
|
||||
synth.getVoices().find((v) => v.lang?.toLowerCase().startsWith("de")) ?? null;
|
||||
chunks.forEach((c, i) => {
|
||||
const u = new SpeechSynthesisUtterance(c.trim());
|
||||
u.lang = "de-DE";
|
||||
if (voice) u.voice = voice;
|
||||
if (i === chunks.length - 1)
|
||||
u.onend = () => {
|
||||
speaking = false;
|
||||
paused = false;
|
||||
};
|
||||
synth.speak(u);
|
||||
});
|
||||
speaking = true;
|
||||
paused = false;
|
||||
}
|
||||
|
||||
function toggleSpeak() {
|
||||
const synth = window.speechSynthesis;
|
||||
if (!speaking) {
|
||||
speak();
|
||||
} else if (paused) {
|
||||
synth.resume();
|
||||
paused = false;
|
||||
} else {
|
||||
synth.pause();
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopSpeak() {
|
||||
window.speechSynthesis.cancel();
|
||||
speaking = false;
|
||||
paused = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window)
|
||||
window.speechSynthesis.cancel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Post-actions toolbar. Each item is a rounded-full pill with a subtle
|
||||
border so they read as visually equal "badges". Order:
|
||||
[info badges] | [action buttons]
|
||||
i.e. info-style items (count, reading time) stand left/right, the
|
||||
three icon-only actions cluster in the middle.
|
||||
-->
|
||||
<div class="inline-flex flex-wrap items-center gap-2 {cls}">
|
||||
{#if commentPageId}
|
||||
<a
|
||||
href="#{commentTargetId}"
|
||||
onclick={onCommentClick}
|
||||
aria-label={t(T.comments_jump_to)}
|
||||
title={t(T.comments_jump_to)}
|
||||
class="no-underline! inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs font-medium text-stein-700! hover:bg-stein-50 hover:border-stein-300 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:message-circle" class="size-3.5" />
|
||||
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="inline-flex items-center rounded-full border border-stein-200 bg-white overflow-hidden divide-x divide-stein-200 print:hidden">
|
||||
{#if canShare}
|
||||
<button
|
||||
type="button"
|
||||
onclick={share}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label={t(T.post_action_share)}
|
||||
title={t(T.post_action_share)}
|
||||
>
|
||||
<Icon
|
||||
icon={shared ? "lucide:check" : "lucide:share-2"}
|
||||
class="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
{#if canSpeak}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSpeak}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors {speaking && !paused ? 'text-wald-700!' : ''}"
|
||||
aria-label={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
|
||||
title={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
|
||||
>
|
||||
<Icon icon={!speaking ? "lucide:volume-2" : paused ? "lucide:play" : "lucide:pause"} class="size-3.5" />
|
||||
</button>
|
||||
{#if speaking}
|
||||
<button
|
||||
type="button"
|
||||
onclick={stopSpeak}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label="Vorlesen stoppen"
|
||||
title="Vorlesen stoppen"
|
||||
>
|
||||
<Icon icon="lucide:square" class="size-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyLink}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
||||
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
||||
>
|
||||
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={print}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label={t(T.post_action_print)}
|
||||
title={t(T.post_action_print)}
|
||||
>
|
||||
<Icon icon="lucide:printer" class="size-3.5" />
|
||||
</button>
|
||||
{#if socialSlug}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (socialModalOpen = true)}
|
||||
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
aria-label="Social-Bild Vorschau"
|
||||
title="Social-Bild Vorschau (für Instagram, Facebook, WhatsApp)"
|
||||
>
|
||||
<Icon icon="lucide:image" class="size-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
<QrButton
|
||||
value={url}
|
||||
label={title}
|
||||
sublabel="Link teilen"
|
||||
class="inline-flex items-center px-2.5 py-1 text-base text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if socialSlug}
|
||||
<SocialImageModal
|
||||
open={socialModalOpen}
|
||||
title={title}
|
||||
slug={socialSlug}
|
||||
endpointBase="/api/social/post"
|
||||
downloadPrefix="beitrag"
|
||||
onclose={() => (socialModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs text-stein-500"
|
||||
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
||||
>
|
||||
<Icon icon="lucide:clock" class="size-3.5" />
|
||||
<span class="tabular-nums"
|
||||
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import PostCard from './PostCard.svelte';
|
||||
|
||||
const CMS_ASSET =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const meta = {
|
||||
title: 'Organisms/PostCard',
|
||||
component: PostCard,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PostCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const post = {
|
||||
_slug: 'infoabend-schleusingen',
|
||||
slug: 'infoabend-schleusingen',
|
||||
headline: 'Infoabend in Schleusingen',
|
||||
subheadline: 'Über 80 Bürgerinnen und Bürger kamen zusammen.',
|
||||
created: '2026-06-12T18:00:00Z',
|
||||
postTag: [{ name: 'Veranstaltung' }, { name: 'Verfahren' }],
|
||||
_resolvedImageUrl: CMS_ASSET,
|
||||
} as never;
|
||||
|
||||
export const MitBild: Story = {
|
||||
args: { post, href: '/posts/infoabend-schleusingen', commentCount: 4 },
|
||||
};
|
||||
|
||||
export const OhneKommentare: Story = {
|
||||
args: { post, href: '/posts/infoabend-schleusingen', commentCount: null },
|
||||
};
|
||||
@@ -1,9 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import PostMeta from "./PostMeta.svelte";
|
||||
import EventBadges from "./EventBadges.svelte";
|
||||
import RustyImage from "./RustyImage.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Badge from "./Badge.svelte";
|
||||
import { getPostEventInfo } from "$lib/blog-utils";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
type PostWithImage = PostEntry & {
|
||||
_resolvedImageUrl?: string;
|
||||
@@ -15,25 +23,24 @@
|
||||
post,
|
||||
href,
|
||||
tagBase = "/posts/tag",
|
||||
commentCount = null,
|
||||
class: className = "",
|
||||
}: {
|
||||
post: PostWithImage;
|
||||
href: string;
|
||||
tagBase?: string;
|
||||
/** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */
|
||||
commentCount?: number | null;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const rawImg = $derived(post._rawImageUrl);
|
||||
const focal = $derived(post._imageFocal ?? null);
|
||||
const fallbackImg = $derived(post._resolvedImageUrl);
|
||||
|
||||
type PostWithEvent = PostWithImage & {
|
||||
isEvent?: boolean;
|
||||
eventDate?: string;
|
||||
eventLocation?: { text?: string };
|
||||
};
|
||||
const p = $derived(post as PostWithEvent);
|
||||
|
||||
const eventDate = $derived(p.isEvent && p.eventDate ? p.eventDate : null);
|
||||
const locationText = $derived(p.eventLocation?.text ?? null);
|
||||
const eventInfo = $derived(getPostEventInfo(post));
|
||||
const eventDate = $derived(eventInfo.eventDate);
|
||||
const locationText = $derived(eventInfo.eventLocation?.text ?? null);
|
||||
|
||||
const tagSlugs = $derived(
|
||||
(post.postTag ?? []).map((t) =>
|
||||
@@ -42,16 +49,26 @@
|
||||
);
|
||||
const hasTerminTag = $derived(tagSlugs.includes("tag-termin"));
|
||||
const isPastEvent = $derived(
|
||||
hasTerminTag && !!p.eventDate && new Date(p.eventDate).getTime() < Date.now(),
|
||||
hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(),
|
||||
);
|
||||
|
||||
// „Neu"-Badge: Beitrag jünger als 7 Tage (nach Erstellung). Nicht bei
|
||||
// vergangenen Terminen.
|
||||
const NEW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const isNew = $derived.by(() => {
|
||||
if (isPastEvent || !post.created) return false;
|
||||
const ts = new Date(post.created).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return Date.now() - ts < NEW_MS;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card
|
||||
{href}
|
||||
layout="post"
|
||||
class={isPastEvent ? "opacity-70 grayscale" : ""}
|
||||
class="{isPastEvent ? 'opacity-70 grayscale' : ''} {className}"
|
||||
>
|
||||
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
|
||||
<div class="relative aspect-3/2 w-full overflow-hidden bg-stein-100">
|
||||
{#if rawImg}
|
||||
<RustyImage
|
||||
src={rawImg}
|
||||
@@ -71,24 +88,50 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-300">
|
||||
<div class="w-full h-full flex items-center justify-center text-stein-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">
|
||||
<div class="absolute bottom-2 left-2 right-2 min-w-0">
|
||||
<EventBadges {eventDate} {locationText} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isPastEvent}
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-800/90 px-2.5 py-1 text-[.65rem] font-semibold uppercase tracking-wide text-white shadow-sm">
|
||||
Vergangen
|
||||
</span>
|
||||
<Badge variant="solid" color="slate" uppercase class="shadow-sm">Vergangen</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{#if isNew}
|
||||
<Badge
|
||||
variant="solid"
|
||||
color="green"
|
||||
uppercase
|
||||
class="inline-flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
<Icon icon="lucide:sparkles" class="size-3" />
|
||||
Neu
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if commentCount != null}
|
||||
<span
|
||||
aria-label={t(T.comments_count_aria, { count: commentCount })}
|
||||
title={t(T.comments_count_aria, { count: commentCount })}
|
||||
>
|
||||
<Badge
|
||||
variant="solid"
|
||||
color="white"
|
||||
class="inline-flex items-center gap-1 tabular-nums shadow-sm"
|
||||
>
|
||||
<Icon icon="lucide:message-circle" class="size-3" />
|
||||
{commentCount}
|
||||
</Badge>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-start p-4">
|
||||
<PostMeta
|
||||
@@ -102,7 +145,7 @@
|
||||
</h5>
|
||||
{#if post.subheadline}
|
||||
<div class="text-xs line-clamp-2 mb-1">
|
||||
{post.subheadline}
|
||||
{@html marked.parseInline(post.subheadline)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.excerpt}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import PostCardCompact from './PostCardCompact.svelte';
|
||||
|
||||
const CMS_ASSET =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const meta = {
|
||||
title: 'Organisms/PostCardCompact',
|
||||
component: PostCardCompact,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PostCardCompact>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
post: {
|
||||
_slug: 'frist-laeuft',
|
||||
slug: 'frist-laeuft',
|
||||
headline: 'Frist für Einwendungen läuft',
|
||||
created: '2026-06-01T09:00:00Z',
|
||||
postTag: [{ name: 'Verfahren' }],
|
||||
_resolvedImageUrl: CMS_ASSET,
|
||||
} as never,
|
||||
href: '/posts/frist-laeuft',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { PostEntry } from "$lib/cms";
|
||||
import RustyImage from "./RustyImage.svelte";
|
||||
import { formatPostDate } from "$lib/blog-utils";
|
||||
|
||||
type PostWithImage = PostEntry & {
|
||||
_resolvedImageUrl?: string;
|
||||
_rawImageUrl?: string;
|
||||
_imageFocal?: { x: number; y: number } | null;
|
||||
};
|
||||
|
||||
let {
|
||||
post,
|
||||
href,
|
||||
}: {
|
||||
post: PostWithImage;
|
||||
href: string;
|
||||
} = $props();
|
||||
|
||||
const rawImg = $derived(post._rawImageUrl);
|
||||
const focal = $derived(post._imageFocal ?? null);
|
||||
const fallbackImg = $derived(post._resolvedImageUrl);
|
||||
|
||||
const dateStr = $derived(formatPostDate(post.created ?? undefined));
|
||||
|
||||
// Alle Tags als schlichter Text (keine Links — die ganze Karte ist EIN <a>,
|
||||
// ein innerer <a> würde die Verschachtelung brechen).
|
||||
const tagNames = $derived(
|
||||
(post.postTag ?? [])
|
||||
.map((t) =>
|
||||
typeof t === "string"
|
||||
? t
|
||||
: ((t as { name?: string; _slug?: string })?.name ?? (t as { _slug?: string })?._slug ?? ""),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="group flex gap-3 overflow-hidden card-surface card-hover-lift p-0 text-stein-900 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div class="relative w-28 shrink-0 self-stretch overflow-hidden bg-stein-100 sm:w-32">
|
||||
{#if rawImg}
|
||||
<RustyImage
|
||||
src={rawImg}
|
||||
focal={focal}
|
||||
width={200}
|
||||
aspect="1/1"
|
||||
alt={post.headline ?? ""}
|
||||
sizes="128px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else if fallbackImg}
|
||||
<img src={fallbackImg} alt={post.headline ?? ""} class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-stein-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-8" 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}
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col justify-center py-3 pr-3">
|
||||
<div class="mb-1 text-xs text-stein-500">
|
||||
{dateStr}{#if tagNames.length} · <span class="text-wald-700">{tagNames.join(", ")}</span>{/if}
|
||||
</div>
|
||||
<h5 class="mb-1 line-clamp-2 text-sm font-semibold leading-tight">
|
||||
{post.headline ?? post.linkName ?? post.slug ?? post._slug}
|
||||
</h5>
|
||||
{#if post.excerpt}
|
||||
<p class="line-clamp-2 text-xs font-light text-stein-500">{post.excerpt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import PostMeta from './PostMeta.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/PostMeta',
|
||||
component: PostMeta,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
date: '2026-06-12T18:00:00Z',
|
||||
tags: [{ name: 'Verfahren' }, { name: 'Natur' }],
|
||||
tagBase: '/posts/tag',
|
||||
},
|
||||
} satisfies Meta<typeof PostMeta>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const NurDatum: Story = { args: { tags: [] } };
|
||||
export const NurTags: Story = { args: { date: null } };
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import QrButton from './QrButton.svelte';
|
||||
|
||||
// Button, der auf Klick ein QR-Modal öffnet (aus `value`).
|
||||
const meta = {
|
||||
title: 'Molecules/QrButton',
|
||||
component: QrButton,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
value: 'https://windwiderstand.de',
|
||||
label: 'QR-Code',
|
||||
sublabel: 'Seite teilen',
|
||||
},
|
||||
} satisfies Meta<typeof QrButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import QrModal from "./QrModal.svelte";
|
||||
|
||||
let {
|
||||
value,
|
||||
label = "",
|
||||
sublabel = "",
|
||||
class: cls = "",
|
||||
}: {
|
||||
value: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="QR-Code anzeigen"
|
||||
onclick={() => (open = true)}
|
||||
class={cls}
|
||||
>
|
||||
<Icon icon="lucide:qr-code" class="size-[1em]" />
|
||||
</button>
|
||||
|
||||
<QrModal value={open ? value : null} {label} {sublabel} onclose={() => (open = false)} />
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import QRCode from "qrcode";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
|
||||
let {
|
||||
value,
|
||||
label = "",
|
||||
sublabel = "",
|
||||
onclose,
|
||||
}: {
|
||||
value: string | null;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let qrSvg = $state("");
|
||||
let qrPngUrl = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (!value) { qrSvg = ""; qrPngUrl = ""; return; }
|
||||
QRCode.toString(value, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 2,
|
||||
color: { dark: "#1c1917", light: "#ffffff" },
|
||||
}).then((svg) => { qrSvg = svg; });
|
||||
QRCode.toDataURL(value, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 2,
|
||||
width: 512,
|
||||
color: { dark: "#1c1917", light: "#ffffff" },
|
||||
}).then((url) => { qrPngUrl = url; });
|
||||
});
|
||||
|
||||
function downloadPng() {
|
||||
if (!qrPngUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = qrPngUrl;
|
||||
a.download = `${(label || "qrcode").toLowerCase().replace(/\s+/g, "-")}.png`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="not-prose fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={onclose}
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-xs rounded-lg border border-stein-200 bg-white p-6 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="absolute right-3 top-3 text-stein-400 hover:text-stein-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<Icon icon="lucide:x" class="size-5" />
|
||||
</button>
|
||||
{#if label}
|
||||
<p class="mb-1 text-sm font-medium text-stein-900">{label}</p>
|
||||
{/if}
|
||||
{#if sublabel}
|
||||
<p class="mb-4 text-xs text-stein-400">{sublabel}</p>
|
||||
{:else}
|
||||
<div class="mb-4"></div>
|
||||
{/if}
|
||||
{#if qrSvg}
|
||||
{@html qrSvg}
|
||||
{:else}
|
||||
<div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div>
|
||||
{/if}
|
||||
<p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={downloadPng}
|
||||
disabled={!qrPngUrl}
|
||||
class="mt-3 w-full"
|
||||
>
|
||||
<Icon icon="lucide:download" class="size-3.5" />
|
||||
Als PNG herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import SearchField from "$lib/ui/SearchField.svelte";
|
||||
|
||||
type SearchHit = {
|
||||
type: "page" | "post";
|
||||
@@ -25,18 +27,72 @@
|
||||
let loadError = $state<string | null>(null);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const MAX_RESULTS = 40;
|
||||
|
||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
||||
const tokens = $derived(normalizedQuery.split(/\s+/).filter((t) => t.length > 0));
|
||||
|
||||
// All tokens must match (AND); score by field weight so title hits rank first.
|
||||
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);
|
||||
const scored: { hit: SearchHit; score: number }[] = [];
|
||||
for (const h of allHits) {
|
||||
const title = h.title.toLowerCase();
|
||||
const subtitle = h.subtitle.toLowerCase();
|
||||
const excerpt = h.excerpt.toLowerCase();
|
||||
const slug = h.slug.toLowerCase();
|
||||
let score = 0;
|
||||
let allMatch = true;
|
||||
for (const tok of tokens) {
|
||||
const inTitle = title.includes(tok);
|
||||
const inSub = subtitle.includes(tok);
|
||||
const inExcerpt = excerpt.includes(tok);
|
||||
const inSlug = slug.includes(tok);
|
||||
if (!(inTitle || inSub || inExcerpt || inSlug)) {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
if (inTitle) score += title.startsWith(tok) ? 12 : 8;
|
||||
if (inSub) score += 4;
|
||||
if (inExcerpt) score += 2;
|
||||
if (inSlug) score += 1;
|
||||
}
|
||||
if (allMatch) scored.push({ hit: h, score });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, MAX_RESULTS).map((s) => s.hit);
|
||||
});
|
||||
|
||||
let activeIndex = $state(-1);
|
||||
let itemEls = $state<(HTMLAnchorElement | null)[]>([]);
|
||||
// Reset cursor whenever the result set changes.
|
||||
$effect(() => {
|
||||
void results;
|
||||
activeIndex = -1;
|
||||
});
|
||||
// Keep the keyboard-selected item visible.
|
||||
$effect(() => {
|
||||
if (activeIndex >= 0) itemEls[activeIndex]?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
function highlight(text: string): string {
|
||||
const safe = escapeHtml(text);
|
||||
if (tokens.length === 0) return safe;
|
||||
const pattern = tokens
|
||||
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|");
|
||||
return safe.replace(
|
||||
new RegExp(`(${pattern})`, "gi"),
|
||||
'<mark class="bg-wald-400/30 text-wald-100 rounded-xs px-0.5">$1</mark>',
|
||||
);
|
||||
}
|
||||
|
||||
async function loadIndex() {
|
||||
if (allHits || loading) return;
|
||||
loading = true;
|
||||
@@ -70,7 +126,26 @@
|
||||
void loadIndex();
|
||||
tick().then(() => inputEl?.focus());
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeSearch();
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
return;
|
||||
}
|
||||
const n = results.length;
|
||||
if (n === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % n;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + n) % n;
|
||||
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const href = results[activeIndex]?.href;
|
||||
if (href) {
|
||||
closeSearch();
|
||||
void goto(href);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -80,79 +155,74 @@
|
||||
{#if open}
|
||||
<div
|
||||
id="search-panel"
|
||||
class="border-t border-white/30 bg-white/95 backdrop-blur overflow-hidden"
|
||||
class="absolute left-0 right-0 top-full z-30 shadow-2xl border-t border-stein-0/10 bg-stein-900/90 backdrop-blur-lg max-h-[calc(100dvh-3.5rem)] overflow-y-auto overscroll-contain"
|
||||
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"
|
||||
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
||||
<SearchField
|
||||
bind:value={query}
|
||||
bind:inputEl
|
||||
tone="dark"
|
||||
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"
|
||||
class="max-w-xl mx-auto"
|
||||
/>
|
||||
{#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">
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
{#if normalizedQuery.length >= 2 && !loading && !loadError}
|
||||
{results.length === 0 ? "Keine Treffer." : `${results.length} Treffer.`}
|
||||
{/if}
|
||||
</p>
|
||||
{#if loading}
|
||||
<p class="text-zinc-500 text-center py-2">Lade Suchindex…</p>
|
||||
<p class="text-stein-300 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>
|
||||
<p class="text-fire-400 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>
|
||||
<p class="text-stein-300 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>
|
||||
<p class="text-stein-300 text-center py-2">Keine Treffer.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-zinc-200">
|
||||
{#each results as hit}
|
||||
<p class="text-stein-400 text-xs mb-2">
|
||||
{results.length === MAX_RESULTS ? `${MAX_RESULTS}+ Treffer` : `${results.length} Treffer`} · ↑↓ zum Wählen, ⏎ zum Öffnen
|
||||
</p>
|
||||
<ul class="divide-y divide-stein-0/10">
|
||||
{#each results as hit, i}
|
||||
<li>
|
||||
<a
|
||||
bind:this={itemEls[i]}
|
||||
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"
|
||||
onmouseenter={() => (activeIndex = i)}
|
||||
aria-current={i === activeIndex ? "true" : undefined}
|
||||
class="flex items-start gap-3 py-3 rounded-xs px-2 -mx-2 no-underline {i === activeIndex ? 'bg-stein-0/10' : 'hover:bg-stein-0/10'}"
|
||||
>
|
||||
{#if hit.image}
|
||||
<img
|
||||
src={hit.image}
|
||||
alt=""
|
||||
class="w-20 h-12 object-cover shrink-0 rounded-sm border border-zinc-200"
|
||||
class="w-20 h-12 object-cover shrink-0 rounded-xs border border-stein-0/10"
|
||||
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 class="w-20 h-12 shrink-0 bg-stein-0/10 rounded-xs border border-stein-0/10 flex items-center justify-center text-stein-400">
|
||||
<Icon icon={hit.type === "post" ? "lucide:file-text" : "lucide:file"} 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">
|
||||
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-stein-300">
|
||||
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
|
||||
{#if hit.isEvent && hit.eventDate}
|
||||
<span class="text-wald-700">{formatDate(hit.eventDate)}</span>
|
||||
<span class="text-wald-300">{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>
|
||||
<div class="font-medium text-stein-0 truncate">{@html highlight(hit.title)}</div>
|
||||
{#if hit.excerpt}
|
||||
<div class="text-xs text-zinc-600 line-clamp-2">{hit.excerpt}</div>
|
||||
<div class="text-xs text-stein-300 line-clamp-2">{@html highlight(hit.excerpt)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SectionGrid from './SectionGrid.svelte';
|
||||
import InfoCard from './InfoCard.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Organisms/SectionGrid',
|
||||
component: SectionGrid,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Reproduktion des Screenshots: Kicker-only-Header + 4 InfoCards. -->
|
||||
<Story name="Direkter Draht" asChild>
|
||||
<div class="p-8">
|
||||
<SectionGrid number="01" kicker="Direkter Draht" columns={4}>
|
||||
<InfoCard
|
||||
icon="mdi:email-outline"
|
||||
title="E-Mail"
|
||||
description="Für alle Fragen und Anregungen"
|
||||
linkLabel="mail@windwiderstand.de"
|
||||
linkHref="mailto:mail@windwiderstand.de"
|
||||
/>
|
||||
<InfoCard
|
||||
icon="mdi:whatsapp"
|
||||
title="WhatsApp-Community"
|
||||
description="Aktuelles und Austausch vor Ort"
|
||||
linkLabel="Beitreten"
|
||||
linkHref="#"
|
||||
external
|
||||
/>
|
||||
<InfoCard
|
||||
icon="mdi:youtube"
|
||||
title="YouTube-Kanal"
|
||||
description="Aufzeichnungen und Visualisierungen"
|
||||
linkLabel="Ansehen"
|
||||
linkHref="#"
|
||||
external
|
||||
/>
|
||||
<InfoCard
|
||||
icon="mdi:cloud-outline"
|
||||
title="Unsere Cloud"
|
||||
description="Dokumente und Materialien zum Mitnehmen"
|
||||
linkLabel="cloud.windwiderstand.de"
|
||||
linkHref="#"
|
||||
external
|
||||
/>
|
||||
</SectionGrid>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<!-- Mit vollem Header (Titel + CTA) und 3 Spalten. -->
|
||||
<Story name="Mit Titel + CTA" asChild>
|
||||
<div class="p-8">
|
||||
<SectionGrid
|
||||
number="03"
|
||||
kicker="Vor Ort"
|
||||
title="Beteiligte Initiativen"
|
||||
subtitle="13 Bürgerinitiativen im Bündnis."
|
||||
actionLabel="Zum Adressbuch"
|
||||
actionHref="/adressbuch"
|
||||
columns={3}
|
||||
>
|
||||
<InfoCard icon="mdi:account-group" title="BI Steigerwald" description="Schleusingen" linkLabel="Mehr" linkHref="#" />
|
||||
<InfoCard icon="mdi:pine-tree" title="Waldfreunde e.V." description="Ebrach" linkLabel="Mehr" linkHref="#" />
|
||||
<InfoCard icon="mdi:leaf" title="Naturschutz Nord" description="Bamberg" linkLabel="Mehr" linkHref="#" />
|
||||
</SectionGrid>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SectionGrid — SectionHeader (Kicker/Nummer/Titel/CTA) oben + responsives
|
||||
* Card-Grid darunter. Die Cards kommen als children-Slot rein (z.B. InfoCard,
|
||||
* Organisations-Karten …). Generisch → auch für Organisationen nutzbar.
|
||||
*
|
||||
* <SectionGrid number="01" kicker="Direkter Draht" columns={4}>
|
||||
* <InfoCard … /> <InfoCard … /> …
|
||||
* </SectionGrid>
|
||||
*/
|
||||
import type { Snippet } from "svelte";
|
||||
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||
|
||||
let {
|
||||
number = undefined,
|
||||
kicker = undefined,
|
||||
title = undefined,
|
||||
subtitle = undefined,
|
||||
actionLabel = undefined,
|
||||
actionHref = undefined,
|
||||
tag = "h2",
|
||||
columns = 4,
|
||||
children,
|
||||
}: {
|
||||
number?: string;
|
||||
kicker?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
tag?: "h1" | "h2" | "h3";
|
||||
/** Spalten auf Desktop (Mobil immer 1, ab sm 2). */
|
||||
columns?: 2 | 3 | 4;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
const hasHeader = $derived(!!number || !!kicker || !!title || (!!actionLabel && !!actionHref));
|
||||
|
||||
// Statische Grid-Klassen (Tailwind kann keine dynamischen Spaltenzahlen scannen).
|
||||
const gridCols: Record<2 | 3 | 4, string> = {
|
||||
2: "sm:grid-cols-2",
|
||||
3: "sm:grid-cols-2 lg:grid-cols-3",
|
||||
4: "sm:grid-cols-2 lg:grid-cols-4",
|
||||
};
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{#if hasHeader}
|
||||
<div class="mb-4">
|
||||
<SectionHeader
|
||||
{number}
|
||||
{kicker}
|
||||
{title}
|
||||
{subtitle}
|
||||
{tag}
|
||||
{actionLabel}
|
||||
{actionHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 {gridCols[columns]}">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import SectionHeader from './SectionHeader.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/SectionHeader',
|
||||
component: SectionHeader,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
number: { control: 'text' },
|
||||
kicker: { control: 'text' },
|
||||
title: { control: 'text' },
|
||||
subtitle: { control: 'text' },
|
||||
actionLabel: { control: 'text' },
|
||||
actionHref: { control: 'text' },
|
||||
tag: { control: 'select', options: ['h1', 'h2', 'h3'] },
|
||||
},
|
||||
} satisfies Meta<typeof SectionHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const MitAktion: Story = {
|
||||
args: {
|
||||
number: '03',
|
||||
kicker: 'Vor Ort',
|
||||
title: 'Beteiligte Initiativen',
|
||||
subtitle: '13 Bürgerinitiativen im Bündnis — direkt vor Ort ansprechbar.',
|
||||
actionLabel: 'Zum Adressbuch',
|
||||
actionHref: '/adressbuch',
|
||||
},
|
||||
};
|
||||
|
||||
export const OhneAktion: Story = {
|
||||
args: { number: '01', kicker: 'Über uns', title: 'Wer sind wir?' },
|
||||
};
|
||||
|
||||
export const OhneNummer: Story = {
|
||||
args: {
|
||||
kicker: 'Aktuelles',
|
||||
title: 'Die letzten Beiträge',
|
||||
actionLabel: 'Alle Beiträge',
|
||||
actionHref: '/aktuelles',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SectionHeader — zusammengesetzter Sektionskopf (Kicker + Titel + Subline + Aktion).
|
||||
* Nutzt HeadlineBlock NICHT (das bleibt CMS-Primitiv für Fließtext-Headlines);
|
||||
* dies ist das wiederverwendbare Layout-Muster für Seitensektionen.
|
||||
*
|
||||
* <SectionHeader number="03" kicker="Vor Ort" title="Beteiligte Initiativen"
|
||||
* subtitle="13 Bürgerinitiativen im Bündnis — direkt vor Ort ansprechbar."
|
||||
* actionLabel="Zum Adressbuch" actionHref="/adressbuch" />
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
import ArrowLink from '$lib/components/ArrowLink.svelte';
|
||||
|
||||
let {
|
||||
number = undefined,
|
||||
kicker = undefined,
|
||||
title = undefined,
|
||||
subtitle = undefined,
|
||||
actionLabel = undefined,
|
||||
actionHref = undefined,
|
||||
tag = 'h2',
|
||||
children = undefined,
|
||||
}: {
|
||||
number?: string; // z. B. "03" — tabular-nums, wald-500
|
||||
kicker?: string; // Label, uppercase — optional (CMS-Blocks liefern oft nur title)
|
||||
title?: string; // optional — z.B. Kicker-only-Header (nur "01 · DIREKTER DRAHT")
|
||||
subtitle?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
tag?: 'h1' | 'h2' | 'h3';
|
||||
children?: Snippet; // optionaler eigener Aktions-Slot rechts
|
||||
} = $props();
|
||||
|
||||
const hasAction = $derived(!!children || (!!actionLabel && !!actionHref));
|
||||
// Kicker-Zeile (Nummer · Linie · Label) nur wenn Nummer ODER Kicker gesetzt —
|
||||
// sonst dangelt die Deko-Linie allein.
|
||||
const hasKickerRow = $derived(!!number || !!kicker);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
|
||||
<div>
|
||||
<!-- Kicker: Nummer · Linie · Label -->
|
||||
{#if hasKickerRow}
|
||||
<div class="mb-3.5 flex items-center gap-3">
|
||||
{#if number}
|
||||
<span class="text-[13px] font-semibold tabular-nums text-wald-500">{number}</span>
|
||||
{/if}
|
||||
<span class="h-px w-7 bg-wald-200"></span>
|
||||
{#if kicker}
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-stein-500">{kicker}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Titel (\n → harter Umbruch, wie HeadlineBlock). Optional — Kicker-only-Header. -->
|
||||
{#if title}
|
||||
<svelte:element this={tag} class="text-3xl font-bold tracking-[-0.02em] text-stein-800 lg:text-[34px]">
|
||||
{#each title.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}
|
||||
</svelte:element>
|
||||
{/if}
|
||||
|
||||
{#if subtitle}
|
||||
<p class="mt-2 text-base text-stein-500">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Aktion (Slot hat Vorrang, sonst Label+Href) -->
|
||||
{#if hasAction}
|
||||
<div class="sm:whitespace-nowrap">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<ArrowLink href={actionHref ?? "#"} label={actionLabel ?? ""} size="md" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
|
||||
let {
|
||||
open,
|
||||
title,
|
||||
slug,
|
||||
endpointBase,
|
||||
downloadPrefix = "social",
|
||||
onclose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
slug: string;
|
||||
/** "/api/social/calendar" oder "/api/social/post" */
|
||||
endpointBase: string;
|
||||
/** Prefix für Download-Dateinamen, z.B. "termin" oder "beitrag" */
|
||||
downloadPrefix?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let theme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
|
||||
let linkCopied = $state(false);
|
||||
|
||||
/** Direktlink, der dieses Modal erneut öffnet — abhängig von endpointBase. */
|
||||
const directLink = $derived.by(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
const origin = window.location.origin;
|
||||
if (endpointBase.includes("/calendar")) {
|
||||
return `${origin}/kalender/?social=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
if (endpointBase.includes("/post")) {
|
||||
return `${origin}/post/${encodeURIComponent(slug)}?social=1`;
|
||||
}
|
||||
return `${origin}/`;
|
||||
});
|
||||
|
||||
async function copyDirectLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(directLink);
|
||||
linkCopied = true;
|
||||
setTimeout(() => (linkCopied = false), 2000);
|
||||
} catch {
|
||||
linkCopied = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading-State pro Format. Skeleton wird angezeigt bis das jeweilige
|
||||
// Bild fertig geladen ist. Bei Theme-Wechsel resetten.
|
||||
let loadedOg = $state(false);
|
||||
let loadedSq = $state(false);
|
||||
let loadedStory = $state(false);
|
||||
$effect(() => {
|
||||
// Reset bei Theme-Änderung (neuer Request → neuer Loading-Zustand)
|
||||
void theme;
|
||||
loadedOg = false;
|
||||
loadedSq = false;
|
||||
loadedStory = false;
|
||||
});
|
||||
|
||||
const themeQ = $derived(theme === "wald" ? "" : `&theme=${theme}`);
|
||||
const ogUrl = $derived(`${endpointBase}/${slug}?format=og${themeQ}`);
|
||||
const sqUrl = $derived(`${endpointBase}/${slug}?format=square${themeQ}`);
|
||||
const storyUrl = $derived(`${endpointBase}/${slug}?format=story${themeQ}`);
|
||||
const previewBg = $derived(
|
||||
theme === "wald"
|
||||
? "bg-stein-50"
|
||||
: theme === "transparent-onlight"
|
||||
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
|
||||
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]",
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Social-Bild-Vorschau"
|
||||
onclick={onclose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs uppercase tracking-wider text-stein-500">Social-Bild</p>
|
||||
<p class="truncate text-sm font-semibold text-stein-800">{title}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyDirectLink}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-stein-300 bg-white px-2 py-1 text-[11px] font-medium text-stein-700 hover:bg-stein-50"
|
||||
title="Direktlink zu dieser Vorschau kopieren"
|
||||
aria-label="Direktlink kopieren"
|
||||
>
|
||||
<Icon icon={linkCopied ? "lucide:check" : "lucide:link"} class="size-3.5" />
|
||||
{linkCopied ? "Kopiert" : "Direktlink"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="text-stein-400 hover:text-stein-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<Icon icon="lucide:x" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
|
||||
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (theme = "wald")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{theme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
>
|
||||
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
|
||||
Wald (Standard)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (theme = "transparent-onlight")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{theme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
title="Transparenter Hintergrund mit dunklem Text"
|
||||
>
|
||||
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
|
||||
Transparent · helle Hintergründe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (theme = "transparent-ondark")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{theme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
title="Transparenter Hintergrund mit hellem Text"
|
||||
>
|
||||
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
|
||||
Transparent · dunkle Hintergründe
|
||||
</button>
|
||||
</div>
|
||||
{#if theme !== "wald"}
|
||||
<p class="mt-2 text-[10px] text-stein-500">
|
||||
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — heruntergeladene PNG hat echten Alpha-Kanal.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p>
|
||||
<div class="relative w-full aspect-[1200/630] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
|
||||
{#if !loadedOg}
|
||||
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
|
||||
{/if}
|
||||
<img
|
||||
src={ogUrl}
|
||||
alt="Social-Bild Quer"
|
||||
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedOg ? 'opacity-100' : 'opacity-0'}"
|
||||
loading="eager"
|
||||
onload={() => (loadedOg = true)}
|
||||
/>
|
||||
</div>
|
||||
<Button href={ogUrl} download={`${downloadPrefix}-${slug}-og-${theme}.png`} variant="outline" size="sm">
|
||||
<Icon icon="lucide:download" class="size-3.5" />
|
||||
Quer herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p>
|
||||
<div class="relative mx-auto w-full aspect-square rounded-sm border border-stein-200 overflow-hidden {previewBg}">
|
||||
{#if !loadedSq}
|
||||
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
|
||||
{/if}
|
||||
<img
|
||||
src={sqUrl}
|
||||
alt="Social-Bild Quadrat"
|
||||
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedSq ? 'opacity-100' : 'opacity-0'}"
|
||||
loading="eager"
|
||||
onload={() => (loadedSq = true)}
|
||||
/>
|
||||
</div>
|
||||
<Button href={sqUrl} download={`${downloadPrefix}-${slug}-square-${theme}.png`} variant="outline" size="sm">
|
||||
<Icon icon="lucide:download" class="size-3.5" />
|
||||
Quadrat herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
|
||||
<div class="relative mx-auto h-[280px] w-[158px] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
|
||||
{#if !loadedStory}
|
||||
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
|
||||
{/if}
|
||||
<img
|
||||
src={storyUrl}
|
||||
alt="Social-Bild Story"
|
||||
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedStory ? 'opacity-100' : 'opacity-0'}"
|
||||
loading="eager"
|
||||
onload={() => (loadedStory = true)}
|
||||
/>
|
||||
</div>
|
||||
<Button href={storyUrl} download={`${downloadPrefix}-${slug}-story-${theme}.png`} variant="outline" size="sm">
|
||||
<Icon icon="lucide:download" class="size-3.5" />
|
||||
Story herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-[11px] text-stein-400">
|
||||
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Tag from './Tag.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/Tag',
|
||||
component: Tag,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Verfahren' },
|
||||
argTypes: {
|
||||
variant: { control: 'select', options: ['white', 'green', 'blue', 'inactive', 'date'] },
|
||||
},
|
||||
} satisfies Meta<typeof Tag>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const White: Story = { args: { variant: 'white' } };
|
||||
export const Green: Story = { args: { variant: 'green' } };
|
||||
export const Blue: Story = { args: { variant: 'blue' } };
|
||||
export const Inactive: Story = { args: { variant: 'inactive' } };
|
||||
export const AlsLink: Story = { args: { variant: 'green', href: '/posts/tag/verfahren' } };
|
||||
export const MitIcon: Story = { args: { variant: 'date', icon: 'lucide:calendar', label: '15.08.2026' } };
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
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";
|
||||
"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 focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1";
|
||||
const variantClass = $derived.by(() => {
|
||||
if (customColor?.trim()) {
|
||||
return "";
|
||||
@@ -62,7 +62,7 @@
|
||||
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 === "blue") return "bg-himmel-100/90 text-himmel-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";
|
||||
@@ -71,7 +71,7 @@
|
||||
const c = customColor?.trim();
|
||||
if (!c) return "";
|
||||
const light = isLightBackground(c);
|
||||
const fg = light ? "#111827" : "#fafafa";
|
||||
const fg = light ? "var(--color-stein-900)" : "var(--color-stein-50)";
|
||||
return `background-color: ${c}; color: ${fg};`;
|
||||
});
|
||||
const interactiveClass = $derived.by(() =>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Tags from './Tags.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/Tags',
|
||||
component: Tags,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
tags: [{ name: 'Verfahren' }, { name: 'Natur' }, { name: 'Landschaft' }],
|
||||
variant: 'green',
|
||||
tagBase: '/posts/tag',
|
||||
},
|
||||
} satisfies Meta<typeof Tags>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const Weiss: Story = { args: { variant: 'white' } };
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import TopBanner from './TopBanner.svelte';
|
||||
|
||||
// Vollbreiter Seiten-Header mit Bild + Headline. `resolvedImages` = fertige
|
||||
// Bild-URL(s); läuft über den /cms-images-Proxy.
|
||||
const CMS_ASSET =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const meta = {
|
||||
title: 'Organisms/TopBanner',
|
||||
component: TopBanner,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
} satisfies Meta<typeof TopBanner>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const MitBild: Story = {
|
||||
args: {
|
||||
banner: null,
|
||||
resolvedImages: [CMS_ASSET],
|
||||
focalCss: '50% 40%',
|
||||
headline: 'Wald statt Windkraft',
|
||||
subheadline: 'Für den Erhalt des Steigerwaldes',
|
||||
},
|
||||
};
|
||||
|
||||
export const NurText: Story = {
|
||||
args: { banner: null, headline: 'Windwiderstand', subheadline: 'Bürgerinitiative Steigerwald' },
|
||||
};
|
||||
@@ -4,13 +4,11 @@
|
||||
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit WebP-Sources
|
||||
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
|
||||
*/
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
interface ResponsiveBannerImage {
|
||||
sources: { type: string; srcset: string }[];
|
||||
sources: { type: string; srcset: string; media?: string }[];
|
||||
fallback: string;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -70,6 +68,13 @@
|
||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
||||
);
|
||||
|
||||
const eyebrowLabel = "Windwiderstand Thüringen";
|
||||
// Eyebrow überall — nur ausblenden, wenn er die Headline dupliziert (Home).
|
||||
const showEyebrow = $derived(
|
||||
(headline?.replace(/<[^>]*>/g, "").trim().toLowerCase() ?? "") !==
|
||||
eyebrowLabel.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasResponsive = $derived(
|
||||
responsive != null && responsive.fallback.trim().length > 0,
|
||||
);
|
||||
@@ -81,47 +86,34 @@
|
||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||
|
||||
let imgEl = $state<HTMLImageElement | null>(null);
|
||||
let imgLoaded = $state(false);
|
||||
|
||||
const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? "");
|
||||
$effect(() => {
|
||||
// reset on src change, then probe cache
|
||||
void currentSrc;
|
||||
imgLoaded = false;
|
||||
queueMicrotask(() => {
|
||||
if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true;
|
||||
});
|
||||
});
|
||||
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
|
||||
// nach der Hydration unsichtbar und schob den LCP von ~3s auf ~8s. Das Bild
|
||||
// malt jetzt progressiv über den LQIP-Background — sichtbar ab erstem Paint.
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<div class="w-full">
|
||||
{#if hasImage}
|
||||
<div
|
||||
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200"
|
||||
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-stein-200"
|
||||
style={wrapperStyle}
|
||||
>
|
||||
{#if hasResponsive && responsive}
|
||||
{#key responsive.fallback}
|
||||
<picture class="block w-full max-w-[1920px] mx-auto">
|
||||
{#each responsive.sources as s (s.type)}
|
||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
||||
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
|
||||
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
|
||||
{/each}
|
||||
<img
|
||||
src={responsive.fallback}
|
||||
alt={altText}
|
||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||
class:opacity-0={!imgLoaded}
|
||||
class:opacity-100={imgLoaded}
|
||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||
width={responsive.width}
|
||||
height={responsive.height}
|
||||
style={imgStyle}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
bind:this={imgEl}
|
||||
onload={() => (imgLoaded = true)}
|
||||
/>
|
||||
</picture>
|
||||
{/key}
|
||||
@@ -130,44 +122,57 @@
|
||||
<img
|
||||
src={resolvedImages[0]}
|
||||
alt={altText}
|
||||
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||
class:opacity-0={!imgLoaded}
|
||||
class:opacity-100={imgLoaded}
|
||||
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||
width={1920}
|
||||
height={600}
|
||||
style={imgStyle}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
onload={() => (imgLoaded = true)}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if hasPageTitle}
|
||||
<div class="absolute inset-0 flex items-center justify-start ">
|
||||
<div class="container-custom mx-auto px-6 relative">
|
||||
<!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 flex items-center justify-start">
|
||||
<div class="container-custom mx-auto px-6">
|
||||
<div class="max-w-xl text-white">
|
||||
{#if showEyebrow}
|
||||
<span class="mb-3 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.08em] text-wald-200">
|
||||
<span class="h-px w-6 bg-wald-400"></span>
|
||||
{eyebrowLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if headlineHtml}
|
||||
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
|
||||
<h1 class="text-4xl font-bold leading-[1.05] tracking-tight text-white! drop-shadow-sm lg:text-5xl">
|
||||
{@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>
|
||||
<h2 class="mt-4 text-xl! font-normal! leading-snug text-stein-100! lg:text-2xl!">
|
||||
{@html subheadlineHtml}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hasPageTitle}
|
||||
<div class="border-b border-zinc-200 container mx-auto px-6 py-4">
|
||||
<div class="border-b border-stein-200 container-custom py-4">
|
||||
{#if headlineHtml}
|
||||
<h1 class="text-2xl font-bold text-zinc-900">{@html headlineHtml}</h1>
|
||||
<h1 class="text-2xl font-bold text-stein-900">{@html headlineHtml}</h1>
|
||||
{/if}
|
||||
{#if subheadlineHtml}
|
||||
<h2 class="text-lg text-zinc-600 mt-1">{@html subheadlineHtml}</h2>
|
||||
<h2 class="text-lg text-stein-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="w-full bg-wald-700 text-white text-center py-2 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="text-sm font-medium">{banner!.text}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import WindAreaPanel from './WindAreaPanel.svelte';
|
||||
import windAreaFixture from './blocks/_fixtures/wind_area.json';
|
||||
|
||||
// Detail-Panel für ein Wind-Vorranggebiet (aus WindkarteMap heraus geöffnet).
|
||||
// Nutzt echtes wind_area-Objekt aus der CMS-Fixture.
|
||||
const area = windAreaFixture.items[0] as never;
|
||||
|
||||
const meta = {
|
||||
title: 'Organisms/WindAreaPanel',
|
||||
component: WindAreaPanel,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof WindAreaPanel>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
area,
|
||||
center: [50.5, 10.75],
|
||||
cmsBase: 'https://cms.pm86.de',
|
||||
onclose: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import type { WindArea, TextFragment } from "$lib/windkarte";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import InfoAccordion from "$lib/components/InfoAccordion.svelte";
|
||||
|
||||
let {
|
||||
area,
|
||||
center,
|
||||
cmsBase,
|
||||
onclose,
|
||||
}: {
|
||||
area: WindArea | null;
|
||||
center: [number, number] | null;
|
||||
cmsBase: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
rechtskraeftig: T.windkarte_status_rechtskraeftig,
|
||||
entwurf_2: T.windkarte_status_entwurf_2,
|
||||
entwurf_3: T.windkarte_status_entwurf_3,
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
rechtskraeftig: "bg-wald-100 text-wald-800",
|
||||
entwurf_2: "bg-erde-100 text-erde-800",
|
||||
entwurf_3: "bg-himmel-100 text-himmel-800",
|
||||
};
|
||||
|
||||
function stellungnahmeHref(s: WindArea["stellungnahme"]): string | null {
|
||||
if (!s) return null;
|
||||
if (typeof s === "string") return `/post/${s}`;
|
||||
if (s._slug) return `/post/${s._slug}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
let kriterien = $state<TextFragment[]>([]);
|
||||
let loadingKriterien = $state(false);
|
||||
let expandedSlug = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const slugs = (area?.stellungnahme_kriterien ?? [])
|
||||
.map((k) => (typeof k === "string" ? k : k._slug))
|
||||
.filter(Boolean);
|
||||
|
||||
kriterien = [];
|
||||
expandedSlug = null;
|
||||
|
||||
if (slugs.length === 0) return;
|
||||
|
||||
loadingKriterien = true;
|
||||
Promise.all(
|
||||
slugs.map((slug) =>
|
||||
fetch(`${cmsBase}/api/content/text_fragment/${slug}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
),
|
||||
).then((results) => {
|
||||
kriterien = results.filter(Boolean) as TextFragment[];
|
||||
loadingKriterien = false;
|
||||
});
|
||||
});
|
||||
|
||||
let copied = $state(false);
|
||||
async function copyLink() {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
function osmHref(c: [number, number] | null): string | null {
|
||||
if (!c) return null;
|
||||
return `https://www.openstreetmap.org/?mlat=${c[0].toFixed(5)}&mlon=${c[1].toFixed(5)}#map=14/${c[0].toFixed(5)}/${c[1].toFixed(5)}`;
|
||||
}
|
||||
|
||||
const exportInfo = $derived(
|
||||
area
|
||||
? [
|
||||
`${area.gebiets_nr}${area.bezeichnung ? ` – ${area.bezeichnung}` : ''}`,
|
||||
area.flaeche_ha != null ? `${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if area}
|
||||
<div class="pointer-events-auto flex h-full flex-col overflow-y-auto bg-stein-0 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3 border-b border-stein-200 px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xl font-semibold text-stein-800">{area.gebiets_nr}</span>
|
||||
{#if area.status && area.status !== 'rechtskraeftig'}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_CLASS[area.status] ?? 'bg-stein-100 text-stein-600'}">
|
||||
{t(STATUS_KEY[area.status] ?? area.status)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if area.bezeichnung}
|
||||
<p class="mt-0.5 text-sm text-stein-500">{area.bezeichnung}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="shrink-0 rounded-md p-1.5 text-stein-400 hover:bg-stein-100 hover:text-stein-700 transition-colors"
|
||||
aria-label={t(T.windkarte_panel_close)}
|
||||
>
|
||||
<Icon icon="lucide:x" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 space-y-5 px-5 py-4 text-sm">
|
||||
<!-- Kenndaten -->
|
||||
<div class="flex flex-col divide-y divide-stein-100 border border-stein-100 rounded-lg overflow-hidden text-sm">
|
||||
{#if area.flaeche_ha != null}
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<span class="flex items-center gap-1.5 text-xs text-stein-500">
|
||||
<Icon icon="lucide:ruler" class="size-3.5 text-stein-400" />
|
||||
{t(T.windkarte_label_flaeche)}
|
||||
</span>
|
||||
<span class="font-semibold text-stein-800">{area.flaeche_ha.toFixed(1)} ha</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if area.anlagen_geplant != null}
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<span class="flex items-center gap-1.5 text-xs text-stein-500">
|
||||
<Icon icon="lucide:fan" class="size-3.5 text-stein-400" />
|
||||
{t(T.windkarte_label_anlagen_geplant)}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 font-semibold text-stein-800">
|
||||
{area.anlagen_geplant}
|
||||
{#if area.anlagen_geplant_hinweis}
|
||||
<Icon icon="lucide:info" class="size-3.5 text-amber-700" />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if area.investor}
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<span class="flex items-center gap-1.5 text-xs text-stein-500">
|
||||
<Icon icon="lucide:building-2" class="size-3.5 text-stein-400" />
|
||||
{t(T.windkarte_label_investor)}
|
||||
</span>
|
||||
<span class="font-semibold text-stein-800">{area.investor}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if area.anlagen_geplant_hinweis}
|
||||
<InfoAccordion
|
||||
title={t(T.windkarte_label_anlagen_hinweis)}
|
||||
body={area.anlagen_geplant_hinweis}
|
||||
variant="amber"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Gemeinden -->
|
||||
{#if area.gemeinden && area.gemeinden.length > 0}
|
||||
<div>
|
||||
<p class="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">
|
||||
<Icon icon="lucide:map-pin" class="size-3.5 text-stein-400" />
|
||||
{t(T.windkarte_label_gemeinden)}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each area.gemeinden as g}
|
||||
<span class="rounded-full bg-himmel-100 px-2.5 py-0.5 text-xs font-medium text-himmel-800">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notizen -->
|
||||
{#if area.notizen}
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_notizen)}</p>
|
||||
<div class="text-stein-700 leading-relaxed markdown prose prose-sm prose-zinc max-w-none">{@html area.notizen}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stellungnahme-Kriterien -->
|
||||
{#if loadingKriterien}
|
||||
<div class="flex items-center gap-2 text-xs text-stein-400">
|
||||
<Icon icon="lucide:loader-circle" class="size-3.5 animate-spin" />
|
||||
<span>{t(T.windkarte_kriterien_loading)}</span>
|
||||
</div>
|
||||
{:else if kriterien.length > 0}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_kriterien)}</p>
|
||||
<div class="divide-y divide-stein-100 rounded-lg border border-stein-100 overflow-hidden">
|
||||
{#each kriterien as k (k._slug)}
|
||||
<div>
|
||||
<button
|
||||
onclick={() => { expandedSlug = expandedSlug === k._slug ? null : k._slug; }}
|
||||
class="flex w-full items-start justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-stein-700 hover:bg-stein-50 transition-colors"
|
||||
>
|
||||
<span class="min-w-0 line-clamp-2">{k.title ?? k._slug}</span>
|
||||
<Icon
|
||||
icon={expandedSlug === k._slug ? "lucide:chevron-up" : "lucide:chevron-down"}
|
||||
class="size-3.5 shrink-0 mt-0.5 text-stein-400"
|
||||
/>
|
||||
</button>
|
||||
{#if expandedSlug === k._slug && k.text}
|
||||
<div class="px-3 pb-3 pt-1 text-xs text-stein-700 leading-relaxed markdown prose prose-xs prose-zinc max-w-none border-t border-stein-100">
|
||||
{@html k.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stellungnahme-Generator (interne URL) -->
|
||||
{#if area.stellungnahmeUrl}
|
||||
<a
|
||||
href={area.stellungnahmeUrl}
|
||||
class="flex items-center gap-2 rounded-lg bg-wald-700 px-4 py-3 text-sm font-semibold text-white no-underline hover:bg-wald-800 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
|
||||
Einwendung erstellen
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Stellungnahme-Vorlage (Post) -->
|
||||
{#if stellungnahmeHref(area.stellungnahme)}
|
||||
<a
|
||||
href={stellungnahmeHref(area.stellungnahme)}
|
||||
class="flex items-center gap-2 rounded-lg border border-wald-300 bg-wald-50 px-4 py-3 text-sm font-medium text-wald-700 hover:bg-wald-100 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
|
||||
{t(T.windkarte_stellungnahme_link)}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button
|
||||
onclick={copyLink}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5 shrink-0" />
|
||||
{copied ? t(T.windkarte_action_copied) : t(T.windkarte_action_copy_link)}
|
||||
</button>
|
||||
{#if osmHref(center)}
|
||||
<a
|
||||
href={osmHref(center)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="lucide:map" class="size-3.5 shrink-0" />
|
||||
{t(T.windkarte_action_osm)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Export-Downloads -->
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_geodaten_export, { nr: area.gebiets_nr })}</p>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/api/vorranggebiete.geojson?id={encodeURIComponent(area.gebiets_nr)}"
|
||||
download
|
||||
title={`${t(T.windkarte_action_download_geojson)}\n${exportInfo}`}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
|
||||
GeoJSON
|
||||
</a>
|
||||
<a
|
||||
href="/api/vorranggebiete.kml?id={encodeURIComponent(area.gebiets_nr)}"
|
||||
download
|
||||
title={`${t(T.windkarte_action_download_kml)}\n${exportInfo}`}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
|
||||
KML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,436 @@
|
||||
<script lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { WindArea } from "$lib/windkarte";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
|
||||
let {
|
||||
areas,
|
||||
onselect,
|
||||
onready = null,
|
||||
initialGebietsNr = null,
|
||||
hiddenStatuses = [],
|
||||
hiddenRings = [],
|
||||
fitBoundsMaxZoom = 11,
|
||||
fitBoundsPadding = null,
|
||||
}: {
|
||||
areas: WindArea[];
|
||||
onselect: (area: WindArea | null, center: [number, number] | null) => void;
|
||||
onready?: ((ctrl: { reset: () => void }) => void) | null;
|
||||
initialGebietsNr?: string | null;
|
||||
hiddenStatuses?: string[];
|
||||
hiddenRings?: string[];
|
||||
fitBoundsMaxZoom?: number;
|
||||
/** Wenn gesetzt: symmetrisches [x,y]-Padding für fitBounds (z.B. Mini-Map
|
||||
* ohne Panel). Default null = panel-aware Padding (großer Bottom-/Side-
|
||||
* Bereich für Detail-Drawer in der vollwertigen Karte). */
|
||||
fitBoundsPadding?: [number, number] | null;
|
||||
} = $props();
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
// Mirror prop into plain variable so Leaflet event handlers read current value.
|
||||
let _hidden: string[] = [];
|
||||
$effect(() => {
|
||||
_hidden = hiddenStatuses;
|
||||
syncLabelsFn?.();
|
||||
if (!geojsonLayer) return;
|
||||
restyleAll(selectedSlug);
|
||||
// Deselect if selected area's status was just hidden.
|
||||
if (selectedSlug) {
|
||||
geojsonLayer.eachLayer((l) => {
|
||||
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
|
||||
if (f?.properties?.name === selectedSlug) {
|
||||
const s = f.properties?.status ?? "rechtskraeftig";
|
||||
if (_hidden.includes(s)) {
|
||||
selectedSlug = null;
|
||||
onselect(null, null);
|
||||
bufferLayer?.clearLayers();
|
||||
if (ring2kmLayer && map?.hasLayer(ring2kmLayer)) map.removeLayer(ring2kmLayer);
|
||||
if (ring5kmLayer && map?.hasLayer(ring5kmLayer)) map.removeLayer(ring5kmLayer);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mapEl: HTMLDivElement;
|
||||
let map: import("leaflet").Map | null = null;
|
||||
let bufferLayer: import("leaflet").LayerGroup | null = null;
|
||||
let ring2kmLayer: import("leaflet").LayerGroup | null = null;
|
||||
let ring5kmLayer: import("leaflet").LayerGroup | null = null;
|
||||
let labelLayers: Record<string, import("leaflet").LayerGroup> = {};
|
||||
let syncLabelsFn: (() => void) | null = null;
|
||||
let selectedSlug = $state<string | null>(null);
|
||||
let geojsonLayer: import("leaflet").GeoJSON | null = null;
|
||||
let buffering = $state(false);
|
||||
let _hiddenRings: string[] = [];
|
||||
|
||||
$effect(() => {
|
||||
_hiddenRings = hiddenRings;
|
||||
if (!map) return;
|
||||
for (const [key, layer] of [["2km", ring2kmLayer], ["5km", ring5kmLayer]] as [string, import("leaflet").LayerGroup | null][]) {
|
||||
if (!layer) continue;
|
||||
if (_hiddenRings.includes(key)) {
|
||||
if (map.hasLayer(layer)) map.removeLayer(layer);
|
||||
} else {
|
||||
if (!map.hasLayer(layer)) map.addLayer(layer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const COLORS: Record<string, { fill: string; stroke: string }> = {
|
||||
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
|
||||
entwurf_2: { fill: "#b08a52", stroke: "#6e5530" },
|
||||
entwurf_2_voraussichtlich:{ fill: "#d4752a", stroke: "#8a4a18" },
|
||||
entwurf_3: { fill: "#436e85", stroke: "#264150" },
|
||||
};
|
||||
|
||||
function style(status: string, selected: boolean, hovered = false) {
|
||||
const c = COLORS[status] ?? COLORS.rechtskraeftig;
|
||||
return {
|
||||
color: c.stroke,
|
||||
fillColor: c.fill,
|
||||
weight: selected ? 2.5 : 1.5,
|
||||
fillOpacity: selected ? 0.55 : hovered ? 0.42 : 0.25,
|
||||
opacity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function restyleAll(sel: string | null) {
|
||||
geojsonLayer?.eachLayer((l) => {
|
||||
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
|
||||
if (!f) return;
|
||||
const s = f.properties?.status ?? "rechtskraeftig";
|
||||
if (_hidden.includes(s)) {
|
||||
(l as import("leaflet").Path).setStyle({ opacity: 0, fillOpacity: 0, weight: 0 });
|
||||
} else {
|
||||
(l as import("leaflet").Path).setStyle(style(s, f.properties?.name === sel));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function drawBuffer(L: typeof import("leaflet"), feature: GeoJSON.Feature) {
|
||||
buffering = true;
|
||||
const { buffer } = await import("@turf/buffer");
|
||||
if (bufferLayer) bufferLayer.clearLayers();
|
||||
else bufferLayer = L.layerGroup().addTo(map!);
|
||||
|
||||
for (const { m, color, dash } of [
|
||||
{ m: 200, color: "#e35651", dash: "4 4" },
|
||||
{ m: 600, color: "#b08a52", dash: "6 3" },
|
||||
{ m: 1000, color: "#436e85", dash: "8 4" },
|
||||
]) {
|
||||
const buffered = buffer(feature, m / 1000, { units: "kilometers" });
|
||||
if (!buffered) continue;
|
||||
L.geoJSON(buffered, {
|
||||
style: { color, fillColor: color, fillOpacity: 0.05, weight: 1.5, dashArray: dash },
|
||||
}).addTo(bufferLayer!);
|
||||
}
|
||||
|
||||
// 2km / 5km — always compute on selection, show/hide controlled by hiddenRings toggle
|
||||
if (!ring2kmLayer) ring2kmLayer = L.layerGroup();
|
||||
else ring2kmLayer.clearLayers();
|
||||
if (!ring5kmLayer) ring5kmLayer = L.layerGroup();
|
||||
else ring5kmLayer.clearLayers();
|
||||
|
||||
const b2 = buffer(feature, 2, { units: "kilometers" });
|
||||
if (b2) L.geoJSON(b2, { style: { color: "#5e6fa3", fillColor: "#5e6fa3", fillOpacity: 0.05, weight: 1.5, dashArray: "10 5" } }).addTo(ring2kmLayer);
|
||||
const b5 = buffer(feature, 5, { units: "kilometers" });
|
||||
if (b5) L.geoJSON(b5, { style: { color: "#8a567a", fillColor: "#8a567a", fillOpacity: 0.05, weight: 1.5, dashArray: "14 6" } }).addTo(ring5kmLayer);
|
||||
|
||||
if (!_hiddenRings.includes("2km") && !map!.hasLayer(ring2kmLayer)) map!.addLayer(ring2kmLayer);
|
||||
if (!_hiddenRings.includes("5km") && !map!.hasLayer(ring5kmLayer)) map!.addLayer(ring5kmLayer);
|
||||
|
||||
buffering = false;
|
||||
}
|
||||
|
||||
async function selectFeature(
|
||||
L: typeof import("leaflet"),
|
||||
feature: GeoJSON.Feature,
|
||||
layer: import("leaflet").Layer,
|
||||
cmsArea: WindArea | null,
|
||||
zoom = true
|
||||
) {
|
||||
const name = feature.properties?.name;
|
||||
const prev = selectedSlug;
|
||||
selectedSlug = name ?? null;
|
||||
restyleAll(selectedSlug);
|
||||
|
||||
const bounds = (layer as import("leaflet").Polygon).getBounds();
|
||||
const c = bounds.getCenter();
|
||||
const center: [number, number] = [c.lat, c.lng];
|
||||
|
||||
if (cmsArea) {
|
||||
onselect(cmsArea, center);
|
||||
await drawBuffer(L, feature);
|
||||
} else {
|
||||
onselect(null, null);
|
||||
bufferLayer?.clearLayers();
|
||||
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
|
||||
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
|
||||
}
|
||||
|
||||
if (zoom && prev !== name) {
|
||||
const desktop = window.innerWidth >= 640;
|
||||
const pad: { paddingTopLeft: [number, number]; paddingBottomRight: [number, number] } =
|
||||
fitBoundsPadding
|
||||
? {
|
||||
paddingTopLeft: fitBoundsPadding,
|
||||
paddingBottomRight: fitBoundsPadding,
|
||||
}
|
||||
: {
|
||||
paddingTopLeft: [40, 40],
|
||||
paddingBottomRight: [desktop ? 320 : 40, desktop ? 40 : 300],
|
||||
};
|
||||
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), {
|
||||
...pad,
|
||||
maxZoom: fitBoundsMaxZoom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const L = await import("leaflet");
|
||||
|
||||
const iconUrl = new URL("leaflet/dist/images/marker-icon.png", import.meta.url).href;
|
||||
const icon2xUrl = new URL("leaflet/dist/images/marker-icon-2x.png", import.meta.url).href;
|
||||
const shadowUrl = new URL("leaflet/dist/images/marker-shadow.png", import.meta.url).href;
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl: icon2xUrl, shadowUrl });
|
||||
|
||||
map = L.map(mapEl, { zoomControl: true, attributionControl: false }).setView([50.62, 10.42], 10);
|
||||
L.control.attribution({ position: "bottomleft" }).addTo(map);
|
||||
// One label LayerGroup per status — lets syncLabels respect hiddenStatuses
|
||||
labelLayers = {
|
||||
rechtskraeftig: L.layerGroup(),
|
||||
entwurf_2: L.layerGroup(),
|
||||
entwurf_2_voraussichtlich: L.layerGroup(),
|
||||
entwurf_3: L.layerGroup(),
|
||||
};
|
||||
onready?.({ reset: () => map!.setView([50.62, 10.42], 10) });
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende | Geodaten: © GDI-Th, dl-de/by-2-0',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const byNr = Object.fromEntries(areas.map((a) => [a.gebiets_nr, a]));
|
||||
|
||||
// Deselect on background map click
|
||||
map.on("click", () => {
|
||||
if (!selectedSlug) return;
|
||||
selectedSlug = null;
|
||||
restyleAll(null);
|
||||
onselect(null, null);
|
||||
bufferLayer?.clearLayers();
|
||||
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
|
||||
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
|
||||
});
|
||||
|
||||
try {
|
||||
// Build FeatureCollection directly from CMS areas (geometry embedded)
|
||||
const geo: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: areas
|
||||
.filter((a) => a.geometry)
|
||||
.map((a) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
name: a.gebiets_nr,
|
||||
bezeichnung: a.bezeichnung,
|
||||
status: a.status ?? "rechtskraeftig",
|
||||
},
|
||||
geometry: a.geometry!,
|
||||
})),
|
||||
};
|
||||
|
||||
const layerMap = new Map<string, { feature: GeoJSON.Feature; layer: import("leaflet").Layer }>();
|
||||
|
||||
geojsonLayer = L.geoJSON(geo, {
|
||||
style: (f) => {
|
||||
const s = f?.properties?.status ?? "rechtskraeftig";
|
||||
return style(s, false);
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const p = feature.properties ?? {};
|
||||
const area = byNr[p.name] ?? { _slug: "", gebiets_nr: p.name ?? "", status: p.status, bezeichnung: p.bezeichnung };
|
||||
feature.properties.cms = area;
|
||||
|
||||
const name = feature.properties?.name ?? "";
|
||||
layerMap.set(name, { feature, layer });
|
||||
|
||||
// Hover highlight
|
||||
layer.on("mouseover", () => {
|
||||
const s = feature.properties?.status ?? "rechtskraeftig";
|
||||
if (_hidden.includes(s)) return;
|
||||
if (feature.properties?.name !== selectedSlug) {
|
||||
(layer as import("leaflet").Path).setStyle(style(s, false, true));
|
||||
}
|
||||
});
|
||||
layer.on("mouseout", () => {
|
||||
const s = feature.properties?.status ?? "rechtskraeftig";
|
||||
if (_hidden.includes(s)) return;
|
||||
if (feature.properties?.name !== selectedSlug) {
|
||||
(layer as import("leaflet").Path).setStyle(style(s, false, false));
|
||||
}
|
||||
});
|
||||
|
||||
layer.on("click", async (e) => {
|
||||
const s = feature.properties?.status ?? "rechtskraeftig";
|
||||
if (_hidden.includes(s)) return;
|
||||
L.DomEvent.stopPropagation(e);
|
||||
await selectFeature(L, feature, layer, area);
|
||||
});
|
||||
|
||||
// Hover tooltip — labels rendered bold via <strong>, an info glyph
|
||||
// gets appended after the count when the editor supplied a hint
|
||||
// string so users know there's more context in the side panel.
|
||||
const nrLabel = area.gebiets_nr ?? p.name ?? "";
|
||||
const title = area.bezeichnung
|
||||
? `${nrLabel} – ${area.bezeichnung}`
|
||||
: nrLabel;
|
||||
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const row = (label: string, value: string, suffix = "") =>
|
||||
`<strong>${esc(label)}:</strong> ${esc(value)}${suffix}`;
|
||||
const lines: string[] = [];
|
||||
if (area.flaeche_ha != null) {
|
||||
lines.push(row(t(T.windkarte_label_flaeche), `${area.flaeche_ha.toFixed(1)} ha`));
|
||||
}
|
||||
if (area.anlagen_geplant != null) {
|
||||
const hint = area.anlagen_geplant_hinweis ? " *" : "";
|
||||
lines.push(row(t(T.windkarte_label_anlagen_geplant), `${area.anlagen_geplant}`, hint));
|
||||
}
|
||||
if (area.gemeinden && area.gemeinden.length > 0) {
|
||||
lines.push(row(t(T.windkarte_label_gemeinden), area.gemeinden.join(', ')));
|
||||
}
|
||||
const tooltipHtml =
|
||||
`<div class="text-sm font-bold pb-1 mb-1 border-b border-stein-200/60">${esc(title)}</div>` +
|
||||
(lines.length ? `<div class="text-xs opacity-90 leading-relaxed">${lines.join('<br/>')}</div>` : '');
|
||||
layer.bindTooltip(tooltipHtml, { sticky: true, className: "windkarte-tooltip" });
|
||||
|
||||
// Permanent label via divIcon marker at polygon center
|
||||
const nr = area.gebiets_nr ?? p.name;
|
||||
const featStatus = (feature?.properties?.status ?? "rechtskraeftig") as string;
|
||||
const targetGroup = labelLayers[featStatus] ?? labelLayers.rechtskraeftig;
|
||||
if (nr && targetGroup) {
|
||||
const center = (layer as import("leaflet").Polygon).getBounds().getCenter();
|
||||
L.marker(center, {
|
||||
icon: L.divIcon({
|
||||
html: `<span class="windkarte-label">${nr}</span>`,
|
||||
className: "windkarte-label-wrap",
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
}),
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
}).addTo(targetGroup);
|
||||
}
|
||||
},
|
||||
}).addTo(map);
|
||||
|
||||
restyleAll(selectedSlug);
|
||||
|
||||
// Labels: show from zoom 13, scale font proportionally with zoom.
|
||||
// Per-status LayerGroup so hidden statuses also hide their labels.
|
||||
const syncLabels = () => {
|
||||
if (!map) return;
|
||||
const z = map.getZoom();
|
||||
const show = z >= 13;
|
||||
const fontSize = Math.max(8, Math.min(26, Math.round(9 * Math.pow(2, z - 13))));
|
||||
for (const [status, layer] of Object.entries(labelLayers)) {
|
||||
const visible = show && !_hidden.includes(status);
|
||||
if (visible) {
|
||||
if (!map.hasLayer(layer)) map.addLayer(layer);
|
||||
layer.eachLayer((l) => {
|
||||
const el = (l as import("leaflet").Marker).getElement();
|
||||
const span = el?.querySelector<HTMLElement>(".windkarte-label");
|
||||
if (span) span.style.fontSize = fontSize + "px";
|
||||
});
|
||||
} else {
|
||||
if (map.hasLayer(layer)) map.removeLayer(layer);
|
||||
}
|
||||
}
|
||||
};
|
||||
syncLabelsFn = syncLabels;
|
||||
map.on("zoomend", syncLabels);
|
||||
syncLabels();
|
||||
|
||||
// Auto-select from hash
|
||||
if (initialGebietsNr) {
|
||||
const entry = layerMap.get(initialGebietsNr);
|
||||
if (entry) {
|
||||
await selectFeature(L, entry.feature, entry.layer, byNr[initialGebietsNr] ?? null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Karte laden fehlgeschlagen:", e);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
map?.remove();
|
||||
map = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={mapEl} class="h-full w-full"></div>
|
||||
|
||||
{#if buffering}
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center z-[600]">
|
||||
<div class="rounded-full bg-white/90 p-2.5 shadow-md">
|
||||
<svg class="size-5 animate-spin text-stein-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.windkarte-tooltip) {
|
||||
background: white;
|
||||
border: 1px solid #d5d8d6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
color: #333735;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
white-space: normal !important;
|
||||
line-height: 1.45;
|
||||
}
|
||||
:global(.windkarte-tooltip::before) {
|
||||
display: none;
|
||||
}
|
||||
/* Auf Touch-Geräten Tooltip ausblenden: Touch öffnet direkt das
|
||||
Detail-Panel, der Tooltip bricht sonst links aus dem Karten-Bereich. */
|
||||
@media (hover: none) {
|
||||
:global(.windkarte-tooltip) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
:global(.windkarte-label-wrap) {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
:global(.windkarte-label) {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
opacity: 0.75;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.12);
|
||||
color: #1a1a1a;
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 4px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import AdressbuchBlock from './AdressbuchBlock.svelte';
|
||||
|
||||
// `resolvedContacts` wird server-seitig injiziert (nicht im CMS-Schema).
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/Adressbuch',
|
||||
component: AdressbuchBlock,
|
||||
tags: ['autodocs'],
|
||||
argTypes: { block: { control: 'object' } },
|
||||
} satisfies Meta<typeof AdressbuchBlock>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
block: {
|
||||
resolvedContacts: [
|
||||
{
|
||||
name: 'Maria Müller',
|
||||
role: 'Sprecherin',
|
||||
email: 'maria@example.org',
|
||||
phone: '01234 567890',
|
||||
organisations: [{ name: 'BI Steigerwald' }],
|
||||
},
|
||||
{
|
||||
name: 'Hans Weber',
|
||||
role: 'Presse',
|
||||
email: 'presse@example.org',
|
||||
organisations: [{ name: 'Waldfreunde e.V.' }],
|
||||
},
|
||||
{ name: 'Petra Schmidt', role: 'Kasse', email: 'kasse@example.org' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types";
|
||||
import { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard";
|
||||
import QrButton from "$lib/components/QrButton.svelte";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import Badge from "$lib/components/Badge.svelte";
|
||||
import SearchField from "$lib/ui/SearchField.svelte";
|
||||
|
||||
let { block }: { block: AdressbuchBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const allContacts = $derived((block.resolvedContacts ?? []) as ContactEntry[]);
|
||||
|
||||
const allOrgs = $derived(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of allContacts) {
|
||||
for (const o of c.organisations ?? []) {
|
||||
if (typeof o === "object" && o !== null && o._slug && o.name) {
|
||||
map.set(o._slug, o.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], "de"));
|
||||
});
|
||||
|
||||
let search = $state("");
|
||||
let activeOrg = $state<string | null>(null);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
let viewMode = $state<"alpha" | "org">("alpha");
|
||||
|
||||
const afterOrgFilter = $derived(() =>
|
||||
allContacts.filter((c) => {
|
||||
if (!activeOrg) return true;
|
||||
return (c.organisations ?? []).some(
|
||||
(o) => (typeof o === "object" ? o._slug : o) === activeOrg
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const availableLetters = $derived(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of afterOrgFilter()) {
|
||||
const first = (c.name ?? "").trim()[0];
|
||||
if (first) set.add(first.toUpperCase());
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
const filtered = $derived(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return afterOrgFilter().filter((c) => {
|
||||
if (activeLetter) {
|
||||
const first = (c.name ?? "").trim()[0]?.toUpperCase();
|
||||
if (first !== activeLetter) return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return (
|
||||
(c.name ?? "").toLowerCase().includes(q) ||
|
||||
(c.role ?? "").toLowerCase().includes(q) ||
|
||||
(c.email ?? "").toLowerCase().includes(q) ||
|
||||
(c.organisations ?? []).some((o) =>
|
||||
typeof o === "object" ? (o.name ?? "").toLowerCase().includes(q) : false
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Alphabetisch gruppiert
|
||||
const alphaGrouped = $derived(() => {
|
||||
const map = new Map<string, ContactEntry[]>();
|
||||
for (const c of filtered()) {
|
||||
const letter = (c.name ?? "").trim()[0]?.toUpperCase() ?? "#";
|
||||
if (!map.has(letter)) map.set(letter, []);
|
||||
map.get(letter)!.push(c);
|
||||
}
|
||||
return [...map.entries()];
|
||||
});
|
||||
|
||||
// Nach Organisation gruppiert
|
||||
const orgGrouped = $derived(() => {
|
||||
const groups = new Map<string, { name: string; contacts: ContactEntry[] }>();
|
||||
const noOrg: ContactEntry[] = [];
|
||||
for (const c of filtered()) {
|
||||
const orgs = (c.organisations ?? []).filter(
|
||||
(o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o._slug && !!o.name
|
||||
);
|
||||
if (orgs.length === 0) {
|
||||
noOrg.push(c);
|
||||
} else {
|
||||
for (const o of orgs) {
|
||||
const slug = o._slug!;
|
||||
if (!groups.has(slug)) groups.set(slug, { name: o.name!, contacts: [] });
|
||||
groups.get(slug)!.contacts.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||||
if (noOrg.length > 0) result.push({ name: "Weitere", contacts: noOrg });
|
||||
return result;
|
||||
});
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function highlight(text: string, q: string): string {
|
||||
if (!q.trim()) return escHtml(text);
|
||||
const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return text
|
||||
.split(new RegExp(`(${safe})`, "gi"))
|
||||
.map((p, i) =>
|
||||
i % 2 === 1
|
||||
? `<mark class="bg-wald-100 text-wald-900 rounded-xs px-px">${escHtml(p)}</mark>`
|
||||
: escHtml(p)
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{layoutClasses} not-prose" data-block-type="adressbuch">
|
||||
<!-- Search + Filter bar -->
|
||||
<div class="no-print mb-4 flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<SearchField
|
||||
bind:value={search}
|
||||
placeholder="Suchen…"
|
||||
ariaLabel="Kontakte durchsuchen"
|
||||
class="max-w-sm flex-1"
|
||||
/>
|
||||
<!-- View toggle -->
|
||||
<div class="flex rounded-md border border-stein-200 overflow-hidden text-[11px] font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { viewMode = "alpha"; activeLetter = null; }}
|
||||
class="px-2 py-1 transition-colors {viewMode === 'alpha' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
|
||||
title="Alphabetisch"
|
||||
>
|
||||
<Icon icon="lucide:arrow-down-a-z" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { viewMode = "org"; activeLetter = null; }}
|
||||
class="px-2 py-1 transition-colors {viewMode === 'org' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
|
||||
title="Nach Organisation"
|
||||
>
|
||||
<Icon icon="lucide:users" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
onclick={() => (activeOrg = null)}
|
||||
class="chip {activeOrg === null
|
||||
? 'border-wald-400 bg-wald-500 text-white'
|
||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{#each allOrgs() as [slug, name]}
|
||||
<button
|
||||
onclick={() => (activeOrg = activeOrg === slug ? null : slug)}
|
||||
class="chip {activeOrg === slug
|
||||
? 'border-wald-400 bg-wald-500 text-white'
|
||||
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if viewMode === "alpha"}
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
{#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter}
|
||||
{@const has = availableLetters().has(letter)}
|
||||
<button
|
||||
onclick={() => (activeLetter = activeLetter === letter ? null : letter)}
|
||||
disabled={!has}
|
||||
class="chip w-6 justify-center border-transparent {activeLetter === letter
|
||||
? 'bg-wald-500 text-white'
|
||||
: has
|
||||
? 'text-stein-700 hover:bg-stein-100'
|
||||
: 'cursor-default text-stein-300'}"
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact list -->
|
||||
{#if filtered().length === 0}
|
||||
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-stein-100 rounded-lg border border-stein-200 bg-white">
|
||||
{#if viewMode === "alpha"}
|
||||
{#each alphaGrouped() as [letter, contacts]}
|
||||
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1 text-[0.65rem] font-semibold tracking-widest text-stein-400">
|
||||
{letter}
|
||||
</div>
|
||||
{#each contacts as contact (contact._slug)}
|
||||
{@render contactRow(contact)}
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each orgGrouped() as group}
|
||||
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1.5 text-xs font-semibold text-stein-600">
|
||||
{group.name}
|
||||
</div>
|
||||
{#each group.contacts as contact (contact._slug)}
|
||||
{@render contactRow(contact)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => downloadVCardAll(filtered())}
|
||||
class="no-print"
|
||||
>
|
||||
<Icon icon="lucide:download" class="size-3.5" />
|
||||
Alle exportieren (.vcf)
|
||||
</Button>
|
||||
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet contactRow(contact: ContactEntry)}
|
||||
<div class="relative flex flex-col gap-2 px-4 py-3">
|
||||
<div class="min-w-0 pr-14">
|
||||
<p class="text-sm font-medium text-stein-900">{@html highlight(contact.name ?? "", search.trim())}</p>
|
||||
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
|
||||
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
|
||||
{#if contact.role}
|
||||
<Badge>{contact.role}</Badge>
|
||||
{/if}
|
||||
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
|
||||
<Badge color="green">{org.name}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions top-right -->
|
||||
<div class="no-print absolute top-3 right-4 flex gap-2 text-base">
|
||||
<QrButton
|
||||
value={buildVCardString(contact)}
|
||||
label={contact.name}
|
||||
sublabel={contact.role}
|
||||
class="text-stein-300 transition-colors hover:text-wald-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Kontakt speichern (.vcf)"
|
||||
onclick={() => downloadVCard(contact)}
|
||||
class="text-stein-300 transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:id-card" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if contact.note}
|
||||
<div class="flex items-center gap-1.5 text-xs text-stein-400">
|
||||
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{contact.note}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contact.phone || contact.email}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if contact.phone}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon icon="lucide:phone" class="size-3.5 shrink-0 text-stein-400" />
|
||||
<span class="min-w-0 truncate text-xs text-stein-600">{contact.phone}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Nummer kopieren"
|
||||
onclick={() => navigator.clipboard.writeText(contact.phone ?? "")}
|
||||
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:copy" class="size-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href="tel:{contact.phone.replace(/\s/g, '')}"
|
||||
title="Anrufen"
|
||||
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon icon="lucide:mail" class="size-3.5 shrink-0 text-stein-400" />
|
||||
<span class="min-w-0 truncate text-xs text-stein-600">{contact.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="E-Mail kopieren"
|
||||
onclick={() => navigator.clipboard.writeText(contact.email ?? "")}
|
||||
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:copy" class="size-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
title="E-Mail schreiben"
|
||||
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
|
||||
>
|
||||
<Icon icon="lucide:arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import CalendarBlock from './CalendarBlock.svelte';
|
||||
// CalendarBlock liest Übersetzungen über die `translations`-Prop (nicht den
|
||||
// Context/WithTranslations-Decorator) → Fixture explizit reinreichen, sonst
|
||||
// rohe Keys (calendar_weekday_mo, calendar_all_upcoming, …).
|
||||
import translations from './_fixtures/translations.json';
|
||||
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/Calendar',
|
||||
component: CalendarBlock,
|
||||
tags: ['autodocs'],
|
||||
argTypes: { block: { control: 'object' } },
|
||||
} satisfies Meta<typeof CalendarBlock>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
_slug: 'infoabend',
|
||||
title: 'Infoabend Schleusingen',
|
||||
terminZeit: '2026-08-15T18:00:00Z',
|
||||
description: 'Vorstellung des Verfahrens und Diskussion.',
|
||||
location: { text: 'Rathaus Schleusingen', lat: 50.51, lng: 10.75 },
|
||||
tags: ['veranstaltung'],
|
||||
},
|
||||
{
|
||||
_slug: 'wanderung',
|
||||
title: 'Waldwanderung',
|
||||
terminZeit: '2026-08-23T10:00:00Z',
|
||||
terminEnde: '2026-08-23T13:00:00Z',
|
||||
description: 'Gemeinsame Wanderung durch das betroffene Gebiet.',
|
||||
location: { text: 'Parkplatz Waldrand' },
|
||||
tags: ['aktion'],
|
||||
},
|
||||
{
|
||||
_slug: 'stammtisch',
|
||||
title: 'Stammtisch',
|
||||
terminZeit: '2026-09-05T19:00:00Z',
|
||||
description: 'Offener Austausch.',
|
||||
tags: ['treffen'],
|
||||
},
|
||||
];
|
||||
|
||||
export const Standard: Story = {
|
||||
args: { block: { title: 'Termine', variant: 'default', fromPost: false, items }, translations },
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import CalendarCompactBlock from './CalendarCompactBlock.svelte';
|
||||
// Übersetzungen laufen über die `translations`-Prop (nicht Context), s. CalendarBlock.
|
||||
import translations from './_fixtures/translations.json';
|
||||
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/CalendarCompact',
|
||||
component: CalendarCompactBlock,
|
||||
tags: ['autodocs'],
|
||||
argTypes: { block: { control: 'object' } },
|
||||
} satisfies Meta<typeof CalendarCompactBlock>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
_slug: 'infoabend',
|
||||
title: 'Infoabend Schleusingen',
|
||||
terminZeit: '2026-08-15T18:00:00Z',
|
||||
location: { text: 'Rathaus Schleusingen' },
|
||||
},
|
||||
{
|
||||
_slug: 'wanderung',
|
||||
title: 'Waldwanderung',
|
||||
terminZeit: '2026-08-23T10:00:00Z',
|
||||
location: { text: 'Parkplatz Waldrand' },
|
||||
},
|
||||
{ _slug: 'stammtisch', title: 'Stammtisch', terminZeit: '2026-09-05T19:00:00Z' },
|
||||
];
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
block: {
|
||||
title: 'Nächste Termine',
|
||||
variant: 'compact',
|
||||
fromPost: false,
|
||||
limit: 3,
|
||||
linkTo: { _slug: 'termine' },
|
||||
items,
|
||||
},
|
||||
translations,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||
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";
|
||||
|
||||
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 limit = $derived(typeof block.limit === "number" && block.limit > 0 ? block.limit : 3);
|
||||
|
||||
/** Compact-Variante zeigt nur aufgelöste Items (Strings = unresolved Slugs → skip). */
|
||||
const allItems = $derived(
|
||||
(block.items ?? []).filter(
|
||||
(x): x is CalendarItemData => typeof x === "object" && x !== null && "terminZeit" in x,
|
||||
),
|
||||
);
|
||||
|
||||
/** Nur zukünftige Termine, aufsteigend sortiert, auf Limit gekürzt. */
|
||||
const upcoming = $derived(
|
||||
allItems
|
||||
.map((it) => ({ it, start: it.terminZeit ? new Date(it.terminZeit) : null }))
|
||||
.filter((x): x is { it: CalendarItemData; start: Date } => {
|
||||
if (!x.start || isNaN(x.start.getTime())) return false;
|
||||
return x.start.getTime() >= Date.now() - 12 * 3600 * 1000; // 12h Toleranz
|
||||
})
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
.slice(0, limit),
|
||||
);
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
function fmtTime(d: Date): string {
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
if (h === 0 && m === 0) return "";
|
||||
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Titel + optional nachgestellte "(…)"-Klammer separat (Klammer gedimmt wie im Design). */
|
||||
function splitTitle(raw: string): { main: string; paren: string } {
|
||||
const m = raw.match(/^(.*?)\s*(\([^()]*\))\s*$/);
|
||||
if (m) return { main: m[1], paren: m[2] };
|
||||
return { main: raw, paren: "" };
|
||||
}
|
||||
|
||||
function locationText(it: CalendarItemData): string {
|
||||
if (!it.location) return "";
|
||||
return typeof it.location === "string" ? it.location : ((it.location as { text?: string })?.text ?? "");
|
||||
}
|
||||
|
||||
const linkHref = $derived(() => {
|
||||
const lt = block.linkTo;
|
||||
if (!lt) return null;
|
||||
const slug = typeof lt === "string" ? lt : (lt._slug ?? "");
|
||||
if (!slug) return null;
|
||||
const clean = slug.replace(/^\/+|\/+$/g, "");
|
||||
return clean ? `/${clean}/` : "/";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="not-prose {layoutClasses} w-full min-w-0"
|
||||
data-block="CalendarCompact"
|
||||
data-block-type="calendar"
|
||||
data-block-variant="compact"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<SectionHeader
|
||||
number={block.number}
|
||||
kicker={block.kicker}
|
||||
title={block.title ?? t(T.calendar_next_events)}
|
||||
tag="h3"
|
||||
actionLabel={linkHref() ? t(T.calendar_all_upcoming) : undefined}
|
||||
actionHref={linkHref() ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if upcoming.length === 0}
|
||||
<p class="rounded-lg border border-stein-200 px-3 py-6 text-center text-sm text-stein-500">
|
||||
{t(T.calendar_no_future_events)}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-stein-100 overflow-hidden rounded-lg border border-stein-200 bg-white">
|
||||
{#each upcoming as { it, start } (it._slug ?? it.title)}
|
||||
{@const timeStr = fmtTime(start)}
|
||||
{@const loc = locationText(it)}
|
||||
{@const title = splitTitle(it.title ?? t(T.calendar_untitled))}
|
||||
{@const base = linkHref()}
|
||||
{@const itemHref = base && it._slug ? `${base}#event-${it._slug}` : null}
|
||||
<li>
|
||||
<svelte:element
|
||||
this={itemHref ? "a" : "div"}
|
||||
href={itemHref ?? undefined}
|
||||
class="flex items-start gap-3 px-4 py-3 no-underline {itemHref ? 'transition-colors hover:bg-stein-50' : ''}"
|
||||
>
|
||||
<div class="flex w-12 shrink-0 flex-col items-center rounded-sm bg-stein-800 px-1 py-1.5 text-white">
|
||||
<span class="text-[0.6rem] font-semibold uppercase leading-none tracking-wide">
|
||||
{start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")}
|
||||
</span>
|
||||
<span class="mt-0.5 text-lg font-bold tabular-nums leading-none">
|
||||
{start.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold leading-snug text-stein-900">
|
||||
{title.main}{#if title.paren}{" "}<span class="font-normal text-stein-400">{title.paren}</span>{/if}
|
||||
</p>
|
||||
<p class="mt-1 flex items-center gap-1.5 text-xs text-stein-500">
|
||||
<Icon icon="lucide:clock" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}{loc ? ` · ${loc}` : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</svelte:element>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||
import InfoCard from "$lib/components/InfoCard.svelte";
|
||||
import InternalComponentBlock from "./InternalComponentBlock.svelte";
|
||||
import type { ContactFormBlockData, InfoCardData } from "$lib/block-types";
|
||||
|
||||
let { block }: { block: ContactFormBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const actionRef = $derived(
|
||||
block.action && typeof block.action === "object" ? block.action : null,
|
||||
);
|
||||
const hasHeader = $derived(!!block.number || !!block.kicker || !!block.headline);
|
||||
const asideHtml = $derived(
|
||||
block.asideText && typeof block.asideText === "string"
|
||||
? (marked.parse(block.asideText) as string)
|
||||
: "",
|
||||
);
|
||||
const cards = $derived(
|
||||
(block.asideCards ?? []).filter(
|
||||
(c): c is InfoCardData => typeof c === "object" && c !== null,
|
||||
),
|
||||
);
|
||||
const formId = $derived(block.form ?? "form-contact");
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={layoutClasses}
|
||||
data-block="ContactForm"
|
||||
data-block-type="contact_form"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if hasHeader}
|
||||
<div class="mb-4">
|
||||
<SectionHeader
|
||||
number={block.number}
|
||||
kicker={block.kicker}
|
||||
title={block.headline}
|
||||
actionLabel={actionRef?.linkName}
|
||||
actionHref={actionRef?.url}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-start">
|
||||
<!-- links: Formular -->
|
||||
<div>
|
||||
<InternalComponentBlock block={{ _type: "internal_component", component: formId }} />
|
||||
</div>
|
||||
|
||||
<!-- rechts: Aside -->
|
||||
<aside class="flex flex-col gap-5">
|
||||
{#if block.asideTitle}
|
||||
<h3 class="text-xl! font-bold! leading-tight! tracking-[-0.01em] text-stein-800">
|
||||
{block.asideTitle}
|
||||
</h3>
|
||||
{/if}
|
||||
{#if asideHtml}
|
||||
<div class="markdown prose prose-stein max-w-none text-stein-500">{@html asideHtml}</div>
|
||||
{/if}
|
||||
{#each cards as card}
|
||||
<InfoCard
|
||||
icon={card.icon}
|
||||
title={card.title ?? ""}
|
||||
description={card.description}
|
||||
linkLabel={card.linkLabel}
|
||||
linkHref={card.linkHref}
|
||||
external={card.external}
|
||||
tone={card.tone}
|
||||
compact
|
||||
/>
|
||||
{/each}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import ContactsBlock from './ContactsBlock.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'CMS-Blocks/Contacts',
|
||||
component: ContactsBlock,
|
||||
tags: ['autodocs'],
|
||||
argTypes: { block: { control: 'object' } },
|
||||
} satisfies Meta<typeof ContactsBlock>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
block: {
|
||||
headline: 'Ansprechpartner',
|
||||
contacts: [
|
||||
{ name: 'Maria Müller', role: 'Sprecherin', email: 'maria@example.org', phone: '01234 567890' },
|
||||
{ name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user