Compare commits
232 Commits
a2ca0db9ca
...
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 |
@@ -89,6 +89,13 @@ jobs:
|
|||||||
IMAGE_CACHE_DIR=/data/image-cache
|
IMAGE_CACHE_DIR=/data/image-cache
|
||||||
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
|
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
|
||||||
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
|
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
|
ENVEOF
|
||||||
|
|
||||||
- name: Pull and restart container
|
- name: Pull and restart container
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ dist/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.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
|
EXPOSE 3001
|
||||||
ENV PORT=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"]
|
CMD ["node", "build"]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
- image-cache:/data/image-cache
|
- image-cache:/data/image-cache
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
image-cache:
|
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
+3402
-19
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -17,18 +17,30 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"prepare": "svelte-kit sync || true"
|
"prepare": "svelte-kit sync || true",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.114",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@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/adapter-node": "^5.2.12",
|
||||||
"@sveltejs/kit": "^2.21.4",
|
"@sveltejs/kit": "^2.65.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
|
"msw": "^2.15.0",
|
||||||
|
"msw-storybook-addon": "^2.0.7",
|
||||||
"openapi-typescript": "^7.4.0",
|
"openapi-typescript": "^7.4.0",
|
||||||
|
"storybook": "^10.5.0",
|
||||||
"svelte": "^5.28.2",
|
"svelte": "^5.28.2",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
@@ -41,10 +53,17 @@
|
|||||||
"@fontsource-variable/rubik": "^5.2.8",
|
"@fontsource-variable/rubik": "^5.2.8",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@iconify/svelte": "^5.2.1",
|
"@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",
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^17.0.2",
|
"marked": "^17.0.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
"satori-html": "^0.3.2",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
|
|||||||
+82
-45
@@ -19,8 +19,6 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const SRC_DIR = path.join(ROOT, 'src');
|
const SRC_DIR = path.join(ROOT, 'src');
|
||||||
const ICONS_JSON = path.join(ROOT, 'node_modules', '@iconify-json', 'mdi', 'icons.json');
|
|
||||||
const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
|
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
|
||||||
@@ -28,40 +26,70 @@ const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.jso
|
|||||||
* pick icons that appear blank — better than shipping the full collection.
|
* pick icons that appear blank — better than shipping the full collection.
|
||||||
*/
|
*/
|
||||||
const EXTRA_ICONS = [
|
const EXTRA_ICONS = [
|
||||||
// PostActions: dynamic share/copy state
|
// Brand/Logo-Icons ohne Lucide-Pendant (Editors picken sie als social.icon)
|
||||||
'share-variant-outline',
|
'google',
|
||||||
'link-variant',
|
'whatsapp',
|
||||||
// Search: file-type fallback
|
'mastodon',
|
||||||
'file-outline',
|
'telegram',
|
||||||
// Common social icons editors pick (Header.svelte: social.icon)
|
// 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',
|
'facebook',
|
||||||
|
'youtube',
|
||||||
|
'map',
|
||||||
|
'video',
|
||||||
'instagram',
|
'instagram',
|
||||||
'twitter',
|
'twitter',
|
||||||
'youtube',
|
|
||||||
'linkedin',
|
'linkedin',
|
||||||
'mastodon',
|
|
||||||
'github',
|
'github',
|
||||||
'email',
|
|
||||||
'email-outline',
|
|
||||||
'phone',
|
'phone',
|
||||||
'whatsapp',
|
// PostActions / Links
|
||||||
'telegram',
|
'share-2',
|
||||||
// Common file-type icons (FilesBlock: f.iconName)
|
'link',
|
||||||
'file-pdf-box',
|
'external-link',
|
||||||
'file-word-box',
|
'arrow-up-right',
|
||||||
'file-excel-box',
|
// 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-image',
|
||||||
'file-video',
|
'file-video',
|
||||||
'file-music',
|
'file-music',
|
||||||
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock)
|
'file-down',
|
||||||
'link',
|
'presentation',
|
||||||
'tag-outline',
|
// Tag/flag/info/format fallbacks
|
||||||
'flag-outline',
|
'tag',
|
||||||
'information-outline',
|
'flag',
|
||||||
|
'info',
|
||||||
|
'type',
|
||||||
|
'image',
|
||||||
|
// Windkarte / Map / Generator
|
||||||
|
'fan',
|
||||||
|
'mouse-pointer-click',
|
||||||
|
'sliders-horizontal',
|
||||||
|
'badge-check',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
|
||||||
|
|
||||||
function walk(dir) {
|
function walk(dir) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
@@ -76,27 +104,28 @@ function walk(dir) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanStatic() {
|
function scanStatic(prefix) {
|
||||||
|
const pattern = new RegExp(`icon="${prefix}:([a-z0-9-]+)"`, 'g');
|
||||||
const found = new Set();
|
const found = new Set();
|
||||||
for (const file of walk(SRC_DIR)) {
|
for (const file of walk(SRC_DIR)) {
|
||||||
const content = fs.readFileSync(file, 'utf8');
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
let m;
|
let m;
|
||||||
while ((m = STATIC_PATTERN.exec(content)) !== null) {
|
while ((m = pattern.exec(content)) !== null) {
|
||||||
found.add(m[1]);
|
found.add(m[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
/** Build a tree-shaken subset for one Iconify collection. */
|
||||||
if (!fs.existsSync(ICONS_JSON)) {
|
function buildSubset({ prefix, pkg, outFile, extra }) {
|
||||||
console.error(
|
const iconsJson = path.join(ROOT, 'node_modules', '@iconify-json', pkg, 'icons.json');
|
||||||
`[generate-icons] missing ${ICONS_JSON} — run \`npm install\` first.`,
|
if (!fs.existsSync(iconsJson)) {
|
||||||
);
|
console.error(`[generate-icons] missing ${iconsJson} — run \`npm install\` first.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8'));
|
const collection = JSON.parse(fs.readFileSync(iconsJson, 'utf8'));
|
||||||
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]);
|
const wanted = new Set([...scanStatic(prefix), ...extra]);
|
||||||
|
|
||||||
const icons = {};
|
const icons = {};
|
||||||
const aliases = {};
|
const aliases = {};
|
||||||
@@ -123,22 +152,30 @@ function main() {
|
|||||||
icons,
|
icons,
|
||||||
...(Object.keys(aliases).length ? { aliases } : {}),
|
...(Object.keys(aliases).length ? { aliases } : {}),
|
||||||
};
|
};
|
||||||
|
fs.writeFileSync(outFile, JSON.stringify(subset, null, 2) + '\n');
|
||||||
|
|
||||||
fs.writeFileSync(OUT_FILE, JSON.stringify(subset, null, 2) + '\n');
|
const sizeAfter = fs.statSync(outFile).size;
|
||||||
|
|
||||||
const sizeBefore = fs.statSync(ICONS_JSON).size;
|
|
||||||
const sizeAfter = fs.statSync(OUT_FILE).size;
|
|
||||||
console.log(
|
console.log(
|
||||||
`[generate-icons] ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, OUT_FILE)}`,
|
`[generate-icons] ${prefix}: ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, outFile)} (${(sizeAfter / 1024).toFixed(1)} KB)`,
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[generate-icons] ${(sizeBefore / 1024 / 1024).toFixed(2)} MB → ${(sizeAfter / 1024).toFixed(1)} KB (${((1 - sizeAfter / sizeBefore) * 100).toFixed(1)}% smaller)`,
|
|
||||||
);
|
);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.warn(
|
console.warn(`[generate-icons] ${prefix}: ${missing.length} not found: ${missing.join(', ')}`);
|
||||||
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${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();
|
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');
|
||||||
+467
-318
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -3,7 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+28
-19
@@ -1,9 +1,10 @@
|
|||||||
import { getTranslationBundleBySlug } from '$lib/cms';
|
import { getNormalizedTranslations } from '$lib/cms';
|
||||||
import { isCmsAvailable } from '$lib/cms-health.server';
|
import { isCmsAvailable } from '$lib/cms-health.server';
|
||||||
import { maintenanceResponse } from '$lib/maintenance.server';
|
import { maintenanceResponse } from '$lib/maintenance.server';
|
||||||
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
||||||
import { env as envPublic } from '$env/dynamic/public';
|
import { env as envPublic } from '$env/dynamic/public';
|
||||||
import { logWarn } from '$lib/log.server';
|
import { logWarn } from '$lib/log.server';
|
||||||
|
import { lookupRedirect } from '$lib/redirects.server';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return maintenanceResponse(200);
|
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
|
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
||||||
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
||||||
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
||||||
@@ -78,23 +97,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' });
|
event.locals.translations = await getNormalizedTranslations(
|
||||||
if (bundle?.strings && typeof bundle.strings === 'object') {
|
TRANSLATION_BUNDLE_SLUG,
|
||||||
const normalized: Record<string, string> = {};
|
{ locale: 'de' },
|
||||||
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 (err) {
|
} catch (err) {
|
||||||
event.locals.translations = {};
|
event.locals.translations = {};
|
||||||
logWarn('hooks.translations', err);
|
logWarn('hooks.translations', err);
|
||||||
@@ -115,7 +121,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
event.url.searchParams.has('preview_draft');
|
event.url.searchParams.has('preview_draft');
|
||||||
const isPreviewDraft = 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' &&
|
event.request.method === 'GET' &&
|
||||||
isCacheablePath(event.url.pathname) &&
|
isCacheablePath(event.url.pathname) &&
|
||||||
!isPreview &&
|
!isPreview &&
|
||||||
|
|||||||
+44
-1
@@ -46,12 +46,36 @@ export type BlockLayout = {
|
|||||||
| "10"
|
| "10"
|
||||||
| "11"
|
| "11"
|
||||||
| "12";
|
| "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. */
|
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
|
||||||
spaceBottom?: 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). */
|
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
|
||||||
breakout?: boolean;
|
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> = {
|
const COL_SPAN: Record<string, string> = {
|
||||||
"1": "col-span-1",
|
"1": "col-span-1",
|
||||||
"2": "col-span-2",
|
"2": "col-span-2",
|
||||||
@@ -105,6 +129,16 @@ const SPACE_BOTTOM: Record<number, string> = {
|
|||||||
2: "mb-8", // 2rem
|
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).
|
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
|
||||||
* Parent muss grid grid-cols-12 haben.
|
* Parent muss grid grid-cols-12 haben.
|
||||||
@@ -126,18 +160,27 @@ export function getBlockLayoutClasses(
|
|||||||
: Number(layout.spaceBottom);
|
: Number(layout.spaceBottom);
|
||||||
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
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) {
|
if (breakout) {
|
||||||
return "w-full";
|
return [spaceTopClass, "w-full"].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobile = String(layout.mobile ?? "12");
|
const mobile = String(layout.mobile ?? "12");
|
||||||
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
|
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
|
||||||
const desktop = layout.desktop != null ? String(layout.desktop) : 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[] = [
|
const parts: string[] = [
|
||||||
COL_SPAN[mobile] ?? "col-span-12",
|
COL_SPAN[mobile] ?? "col-span-12",
|
||||||
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
|
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
|
||||||
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
|
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
|
||||||
|
tabletStart ? (MD_COL_START[tabletStart] ?? "") : "",
|
||||||
|
desktopStart ? (LG_COL_START[desktopStart] ?? "") : "",
|
||||||
|
spaceTopClass,
|
||||||
spaceClass,
|
spaceClass,
|
||||||
];
|
];
|
||||||
return parts.filter(Boolean).join(" ");
|
return parts.filter(Boolean).join(" ");
|
||||||
|
|||||||
+289
-5
@@ -45,6 +45,12 @@ export interface HeadlineBlockData {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||||
text?: string;
|
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";
|
align?: "left" | "center" | "right";
|
||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
}
|
}
|
||||||
@@ -121,6 +127,12 @@ export interface ImageGalleryBlockData {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** Optional: oberhalb des Galerie-Modals als Header. */
|
/** Optional: oberhalb des Galerie-Modals als Header. */
|
||||||
title?: string;
|
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. */
|
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
|
||||||
variant?: "standard" | "grid";
|
variant?: "standard" | "grid";
|
||||||
images?: ImageGalleryImage[];
|
images?: ImageGalleryImage[];
|
||||||
@@ -144,6 +156,12 @@ export interface FilesBlockData {
|
|||||||
_type?: "files";
|
_type?: "files";
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
headline?: 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;
|
description?: string;
|
||||||
items?: Array<string | FilesBlockItem>;
|
items?: Array<string | FilesBlockItem>;
|
||||||
forceDownload?: boolean;
|
forceDownload?: boolean;
|
||||||
@@ -174,6 +192,12 @@ export interface YoutubeVideoGalleryBlockData {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: 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;
|
description?: string;
|
||||||
variant?: "carousel" | "grid";
|
variant?: "carousel" | "grid";
|
||||||
videos?: YoutubeVideoBlockData[];
|
videos?: YoutubeVideoBlockData[];
|
||||||
@@ -216,6 +240,8 @@ export interface LinkListBlockData {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
headline?: string;
|
headline?: string;
|
||||||
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
|
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
|
||||||
|
/** Spaltenlayout: "auto" = mehrspaltig bei >4 Links, "single" = immer untereinander. */
|
||||||
|
columns?: "auto" | "single";
|
||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +250,12 @@ export interface PostOverviewBlockData {
|
|||||||
_type?: "post_overview";
|
_type?: "post_overview";
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
headline?: 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). */
|
/** Einleitungstext (Markdown). */
|
||||||
text?: string;
|
text?: string;
|
||||||
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
|
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
|
||||||
@@ -236,8 +268,8 @@ export interface PostOverviewBlockData {
|
|||||||
excludeTag?: string[];
|
excludeTag?: string[];
|
||||||
/** Max. Anzahl Einträge. */
|
/** Max. Anzahl Einträge. */
|
||||||
numberItems?: number;
|
numberItems?: number;
|
||||||
/** "list" | "cards". */
|
/** "list" | "cards" | "carousel" | "featured" (1. Post groß links, Rest kompakt rechts). */
|
||||||
design?: "list" | "cards";
|
design?: "list" | "cards" | "carousel" | "featured";
|
||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
|
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
|
||||||
postsResolved?: unknown[];
|
postsResolved?: unknown[];
|
||||||
@@ -284,6 +316,12 @@ export interface SearchableTextBlockData {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
/** Titel der Suchkomponente. */
|
/** Titel der Suchkomponente. */
|
||||||
title?: string;
|
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). */
|
/** Einleitung (Markdown). */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Slugs oder aufgelöste text_fragment-Einträge. */
|
/** Slugs oder aufgelöste text_fragment-Einträge. */
|
||||||
@@ -313,6 +351,10 @@ export interface OrganisationEntry {
|
|||||||
description?: string;
|
description?: string;
|
||||||
link?: string | { url?: string; linkName?: string; newTab?: boolean };
|
link?: string | { url?: string; linkName?: string; newTab?: boolean };
|
||||||
badge?: string | { label?: string; color?: string };
|
badge?: string | { label?: string; color?: string };
|
||||||
|
whatsapp?: string;
|
||||||
|
youtube?: string;
|
||||||
|
location?: { text?: string; lat?: number; lng?: number };
|
||||||
|
contacts?: (string | ContactEntry)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Organisations-Block (_type: "organisations"). */
|
/** Organisations-Block (_type: "organisations"). */
|
||||||
@@ -320,9 +362,15 @@ export interface OrganisationsBlockData {
|
|||||||
_type?: "organisations";
|
_type?: "organisations";
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
headline?: 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;
|
description?: string;
|
||||||
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
|
/** 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;
|
addOrganisationTitle?: string;
|
||||||
addOrganisationMarkdown?: string;
|
addOrganisationMarkdown?: string;
|
||||||
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
|
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
|
||||||
@@ -331,8 +379,125 @@ export interface OrganisationsBlockData {
|
|||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. */
|
export interface OrganisationsMapBlockData {
|
||||||
export type CalendarItemData = components["schemas"]["calendar_item"];
|
_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). */
|
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
|
||||||
export type CalendarBlockData = Omit<
|
export type CalendarBlockData = Omit<
|
||||||
@@ -342,7 +507,126 @@ export type CalendarBlockData = Omit<
|
|||||||
_type?: "calendar";
|
_type?: "calendar";
|
||||||
/** Optionaler Titel über dem Kalender. */
|
/** Optionaler Titel über dem Kalender. */
|
||||||
title?: string;
|
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. */
|
/** Slugs oder aufgelöste calendar_item-Einträge. */
|
||||||
items?: (string | CalendarItemData)[];
|
items?: (string | CalendarItemData)[];
|
||||||
layout?: BlockLayout;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+268
-97
@@ -4,10 +4,15 @@ import {
|
|||||||
getPostBySlug,
|
getPostBySlug,
|
||||||
getTags,
|
getTags,
|
||||||
getTextFragmentBySlug,
|
getTextFragmentBySlug,
|
||||||
|
getTextFragmentsByTag,
|
||||||
|
getEntryBySlug,
|
||||||
|
getStellungnahmeConfig,
|
||||||
getCalendarBySlug,
|
getCalendarBySlug,
|
||||||
getCalendarItemBySlug,
|
getCalendarItemBySlug,
|
||||||
|
getContacts,
|
||||||
getCalendarItems,
|
getCalendarItems,
|
||||||
} from "./cms";
|
} from "./cms";
|
||||||
|
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
|
||||||
import type { RowContentLayout } from "./block-types";
|
import type { RowContentLayout } from "./block-types";
|
||||||
import type { CalendarItemData } from "./block-types";
|
import type { CalendarItemData } from "./block-types";
|
||||||
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
|
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). */
|
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
|
||||||
export function getPostImageUrl(
|
export function getPostImageUrl(
|
||||||
postImage: PostEntry["postImage"],
|
postImage: PostEntry["postImage"],
|
||||||
@@ -200,34 +243,37 @@ export type PostSearchIndexEntry = {
|
|||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PostWithEventIndex = PostEntry & {
|
type PostWithImageIndex = PostEntry & {
|
||||||
isEvent?: boolean;
|
|
||||||
eventDate?: string;
|
|
||||||
_resolvedImageUrl?: string;
|
_resolvedImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
||||||
return posts.map((p) => ({
|
return posts.map((p) => {
|
||||||
slug: p.slug ?? p._slug ?? "",
|
const ev = getPostEventInfo(p);
|
||||||
headline: p.headline ?? "",
|
return {
|
||||||
excerpt: p.excerpt ?? "",
|
slug: p.slug ?? p._slug ?? "",
|
||||||
subheadline: p.subheadline ?? "",
|
headline: p.headline ?? "",
|
||||||
tags: (p.postTag ?? [])
|
excerpt: p.excerpt ?? "",
|
||||||
.map((t) =>
|
subheadline: p.subheadline ?? "",
|
||||||
typeof t === "string"
|
tags: (p.postTag ?? [])
|
||||||
? t
|
.map((t) =>
|
||||||
: (t as { name?: string; _slug?: string }).name ??
|
typeof t === "string"
|
||||||
(t as { _slug?: string })._slug ??
|
? t
|
||||||
"",
|
: (t as { name?: string; _slug?: string }).name ??
|
||||||
)
|
(t as { _slug?: string })._slug ??
|
||||||
.filter(Boolean),
|
"",
|
||||||
image: (p as PostWithEventIndex)._resolvedImageUrl ?? null,
|
)
|
||||||
created: p.created ?? null,
|
.filter(Boolean),
|
||||||
isEvent: Boolean((p as PostWithEventIndex).isEvent),
|
image: (p as PostWithImageIndex)._resolvedImageUrl ?? null,
|
||||||
eventDate: (p as PostWithEventIndex).eventDate ?? null,
|
created: p.created ?? null,
|
||||||
}));
|
isEvent: ev.isEvent,
|
||||||
|
eventDate: ev.eventDate,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PostSortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
export type LoadPostsResult = {
|
export type LoadPostsResult = {
|
||||||
posts: PostEntry[];
|
posts: PostEntry[];
|
||||||
tags: Awaited<ReturnType<typeof getTags>>;
|
tags: Awaited<ReturnType<typeof getTags>>;
|
||||||
@@ -235,6 +281,7 @@ export type LoadPostsResult = {
|
|||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalPosts: number;
|
totalPosts: number;
|
||||||
|
sort: PostSortOrder;
|
||||||
upcomingEvents: PostEntry[];
|
upcomingEvents: PostEntry[];
|
||||||
searchIndex: PostSearchIndexEntry[];
|
searchIndex: PostSearchIndexEntry[];
|
||||||
tagName: string | null;
|
tagName: string | null;
|
||||||
@@ -245,9 +292,11 @@ export type LoadPostsResult = {
|
|||||||
export async function loadPostsList(opts: {
|
export async function loadPostsList(opts: {
|
||||||
tagSlug?: string | null;
|
tagSlug?: string | null;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
sort?: PostSortOrder;
|
||||||
}): Promise<LoadPostsResult> {
|
}): Promise<LoadPostsResult> {
|
||||||
const tagSlug = opts.tagSlug ?? null;
|
const tagSlug = opts.tagSlug ?? null;
|
||||||
const requestedPage = Math.max(1, opts.page ?? 1);
|
const requestedPage = Math.max(1, opts.page ?? 1);
|
||||||
|
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
|
||||||
const perPage = getPostsPerPage();
|
const perPage = getPostsPerPage();
|
||||||
|
|
||||||
let posts: PostEntry[] = [];
|
let posts: PostEntry[] = [];
|
||||||
@@ -260,7 +309,7 @@ export async function loadPostsList(opts: {
|
|||||||
// (post → postImage → img-Sidecar; post → postTag → name/icon/color).
|
// (post → postImage → img-Sidecar; post → postTag → name/icon/color).
|
||||||
[posts, tags] = await Promise.all([
|
[posts, tags] = await Promise.all([
|
||||||
getPosts({
|
getPosts({
|
||||||
resolve: ["postImage", "postTag"],
|
resolve: ["postImage", "postTag", "calendarItem"],
|
||||||
depth: 2,
|
depth: 2,
|
||||||
fields: [
|
fields: [
|
||||||
"_slug",
|
"_slug",
|
||||||
@@ -270,8 +319,7 @@ export async function loadPostsList(opts: {
|
|||||||
"subheadline",
|
"subheadline",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"created",
|
"created",
|
||||||
"isEvent",
|
"calendarItem",
|
||||||
"eventDate",
|
|
||||||
"hideFromListing",
|
"hideFromListing",
|
||||||
"postImage.*",
|
"postImage.*",
|
||||||
"postTag.name",
|
"postTag.name",
|
||||||
@@ -288,33 +336,32 @@ export async function loadPostsList(opts: {
|
|||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
const field = getPostImageField(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (field) {
|
if (field) {
|
||||||
try {
|
const url = ensureTransformedImage(field.url, {
|
||||||
const url = await ensureTransformedImage(field.url, {
|
width: 400,
|
||||||
width: 400,
|
height: 267,
|
||||||
height: 267,
|
fit: "cover",
|
||||||
fit: "cover",
|
format: "webp",
|
||||||
format: "webp",
|
focalX: field.focal?.x,
|
||||||
focalX: field.focal?.x,
|
focalY: field.focal?.y,
|
||||||
focalY: field.focal?.y,
|
});
|
||||||
});
|
const p = post as PostEntry & {
|
||||||
const p = post as PostEntry & {
|
_resolvedImageUrl?: string;
|
||||||
_resolvedImageUrl?: string;
|
_rawImageUrl?: string;
|
||||||
_rawImageUrl?: string;
|
_imageFocal?: { x: number; y: number } | null;
|
||||||
_imageFocal?: { x: number; y: number } | null;
|
};
|
||||||
};
|
p._resolvedImageUrl = url;
|
||||||
p._resolvedImageUrl = url;
|
p._rawImageUrl = field.url;
|
||||||
p._rawImageUrl = field.url;
|
p._imageFocal = field.focal ?? null;
|
||||||
p._imageFocal = field.focal ?? null;
|
|
||||||
} catch {
|
|
||||||
/* image optional */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cmsError = e instanceof Error ? e.message : String(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 totalPages = getTotalPages(filtered.length, perPage);
|
||||||
const pageNum = Math.min(requestedPage, totalPages);
|
const pageNum = Math.min(requestedPage, totalPages);
|
||||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||||
@@ -333,6 +380,7 @@ export async function loadPostsList(opts: {
|
|||||||
currentPage: pageNum,
|
currentPage: pageNum,
|
||||||
totalPages,
|
totalPages,
|
||||||
totalPosts: filtered.length,
|
totalPosts: filtered.length,
|
||||||
|
sort,
|
||||||
upcomingEvents,
|
upcomingEvents,
|
||||||
searchIndex,
|
searchIndex,
|
||||||
tagName,
|
tagName,
|
||||||
@@ -342,16 +390,18 @@ export async function loadPostsList(opts: {
|
|||||||
|
|
||||||
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
|
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return (posts as PostWithEventIndex[])
|
type Scored = { post: PostEntry; ts: number };
|
||||||
.filter(
|
const scored: Scored[] = [];
|
||||||
(p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= now,
|
for (const p of posts) {
|
||||||
)
|
const ev = getPostEventInfo(p);
|
||||||
.sort(
|
if (!ev.isEvent || !ev.eventDate) continue;
|
||||||
(a, b) =>
|
const ts = new Date(ev.eventDate).getTime();
|
||||||
new Date(a.eventDate ?? 0).getTime() -
|
if (ts >= now) scored.push({ post: p, ts });
|
||||||
new Date(b.eventDate ?? 0).getTime(),
|
}
|
||||||
)
|
return scored
|
||||||
.slice(0, max) as PostEntry[];
|
.sort((a, b) => a.ts - b.ts)
|
||||||
|
.slice(0, max)
|
||||||
|
.map((s) => s.post);
|
||||||
}
|
}
|
||||||
|
|
||||||
const POST_BASE = "/post";
|
const POST_BASE = "/post";
|
||||||
@@ -472,15 +522,14 @@ export async function resolvePostOverviewBlocks(
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const limit =
|
const limit =
|
||||||
typeof item.numberItems === "number" ? item.numberItems : 9999;
|
typeof item.numberItems === "number" ? item.numberItems : 9999;
|
||||||
const resolved: PostEntry[] = [];
|
const settled = await Promise.all(
|
||||||
for (const slug of slugs.slice(0, limit)) {
|
slugs.slice(0, limit).map((slug) =>
|
||||||
const post = await getPostBySlug(slug, {
|
getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
|
||||||
locale: "de",
|
() => null,
|
||||||
resolve: ["all"],
|
),
|
||||||
});
|
),
|
||||||
if (post) resolved.push(post);
|
);
|
||||||
}
|
item.postsResolved = settled.filter((p): p is PostEntry => !!p);
|
||||||
item.postsResolved = resolved;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(item.postsResolved)) {
|
if (Array.isArray(item.postsResolved)) {
|
||||||
@@ -491,26 +540,22 @@ export async function resolvePostOverviewBlocks(
|
|||||||
for (const post of item.postsResolved as PostEntry[]) {
|
for (const post of item.postsResolved as PostEntry[]) {
|
||||||
const field = getPostImageField(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (field) {
|
if (field) {
|
||||||
try {
|
const url = ensureTransformedImage(field.url, {
|
||||||
const url = await ensureTransformedImage(field.url, {
|
width: 400,
|
||||||
width: 400,
|
height: 267,
|
||||||
height: 267,
|
fit: "cover",
|
||||||
fit: "cover",
|
format: "webp",
|
||||||
format: "webp",
|
focalX: field.focal?.x,
|
||||||
focalX: field.focal?.x,
|
focalY: field.focal?.y,
|
||||||
focalY: field.focal?.y,
|
});
|
||||||
});
|
const p = post as PostEntry & {
|
||||||
const p = post as PostEntry & {
|
_resolvedImageUrl?: string;
|
||||||
_resolvedImageUrl?: string;
|
_rawImageUrl?: string;
|
||||||
_rawImageUrl?: string;
|
_imageFocal?: { x: number; y: number } | null;
|
||||||
_imageFocal?: { x: number; y: number } | null;
|
};
|
||||||
};
|
p._resolvedImageUrl = url;
|
||||||
p._resolvedImageUrl = url;
|
p._rawImageUrl = field.url;
|
||||||
p._rawImageUrl = field.url;
|
p._imageFocal = field.focal ?? null;
|
||||||
p._imageFocal = field.focal ?? null;
|
|
||||||
} catch {
|
|
||||||
// Bild optional
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,19 +607,35 @@ export async function resolveCalendarBlocks(
|
|||||||
resolve: "items",
|
resolve: "items",
|
||||||
});
|
});
|
||||||
const raw = calendar?.items ?? rawItems;
|
const raw = calendar?.items ?? rawItems;
|
||||||
const resolved: CalendarItemData[] = [];
|
// Leere items-Liste → alle calendar_items aus der Collection (Pool).
|
||||||
for (const x of raw) {
|
// Greift sowohl für default-Variante als auch compact-Anteaser.
|
||||||
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
|
if (raw.length === 0) {
|
||||||
resolved.push(x as CalendarItemData);
|
const pool = (await getCalendarItems({ locale: "de" })) as unknown as CalendarItemData[];
|
||||||
} else {
|
(item as { items?: CalendarItemData[] }).items = pool;
|
||||||
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
|
continue;
|
||||||
if (islug) {
|
|
||||||
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
|
|
||||||
if (entry) resolved.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(item as { items?: CalendarItemData[] }).items = resolved;
|
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;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,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
|
* @default false
|
||||||
*/
|
*/
|
||||||
external: boolean;
|
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;
|
icon?: string;
|
||||||
/** @description Internal key. Not used in app. */
|
/** @description Internal key. Not used in app. */
|
||||||
internal?: string;
|
internal?: string;
|
||||||
@@ -4065,7 +4065,7 @@ export interface components {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
external: boolean;
|
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;
|
icon?: string;
|
||||||
/** @description Internal key. Not used in app. */
|
/** @description Internal key. Not used in app. */
|
||||||
internal?: string;
|
internal?: string;
|
||||||
@@ -5400,7 +5400,7 @@ export interface components {
|
|||||||
readonly _slug?: string;
|
readonly _slug?: string;
|
||||||
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
||||||
color?: string;
|
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;
|
icon?: string;
|
||||||
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5410,7 +5410,7 @@ export interface components {
|
|||||||
_slug: string;
|
_slug: string;
|
||||||
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
|
||||||
color?: string;
|
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;
|
icon?: string;
|
||||||
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
/** @description Tag name (e.g. politik, termin). Must be unique. */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5446,6 +5446,10 @@ export interface components {
|
|||||||
id: string;
|
id: string;
|
||||||
/** @description Banner text (e.g. notice at top of page) */
|
/** @description Banner text (e.g. notice at top of page) */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** @description Optional URL the banner links to */
|
||||||
|
link?: string;
|
||||||
|
/** @description CTA label for the link */
|
||||||
|
linkLabel?: string;
|
||||||
};
|
};
|
||||||
top_banner_input: {
|
top_banner_input: {
|
||||||
/** @description URL slug (used as filename) */
|
/** @description URL slug (used as filename) */
|
||||||
@@ -5454,6 +5458,10 @@ export interface components {
|
|||||||
id: string;
|
id: string;
|
||||||
/** @description Banner text (e.g. notice at top of page) */
|
/** @description Banner text (e.g. notice at top of page) */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** @description Optional URL the banner links to */
|
||||||
|
link?: string;
|
||||||
|
/** @description CTA label for the link */
|
||||||
|
linkLabel?: string;
|
||||||
};
|
};
|
||||||
translation_bundle: {
|
translation_bundle: {
|
||||||
/** @description Entry identifier (filename) */
|
/** @description Entry identifier (filename) */
|
||||||
|
|||||||
+255
-25
@@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
|
|||||||
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
|
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
|
||||||
*/
|
*/
|
||||||
const CACHE_TTL: Record<string, number> = {
|
const CACHE_TTL: Record<string, number> = {
|
||||||
page: 5 * 60_000,
|
// Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
|
||||||
page_config: 10 * 60_000,
|
// Cold-Loads + CMS-Roundtrips.
|
||||||
navigation: 10 * 60_000,
|
page: 60 * 60_000, // 1h (war 5min)
|
||||||
footer: 10 * 60_000,
|
page_config: 60 * 60_000, // 1h
|
||||||
post: 60_000,
|
navigation: 60 * 60_000, // 1h
|
||||||
tag: 5 * 60_000,
|
footer: 60 * 60_000, // 1h
|
||||||
text_fragment: 5 * 60_000,
|
post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
|
||||||
fullwidth_banner: 5 * 60_000,
|
tag: 30 * 60_000, // 30min
|
||||||
calendar: 60_000,
|
text_fragment: 30 * 60_000, // 30min
|
||||||
calendar_item: 60_000,
|
fullwidth_banner: 60 * 60_000, // 1h
|
||||||
translation: 5 * 60_000,
|
calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
|
||||||
translation_bundle: 5 * 60_000,
|
calendar_item: 5 * 60_000, // 5min
|
||||||
openapi: 10 * 60_000,
|
translation: 60 * 60_000, // 1h
|
||||||
default: 60_000,
|
translation_bundle: 60 * 60_000, // 1h
|
||||||
|
openapi: 60 * 60_000, // 1h
|
||||||
|
default: 5 * 60_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CacheEntry<T> = { value: T; expires: number };
|
type CacheEntry<T> = { value: T; expires: number };
|
||||||
@@ -88,14 +90,19 @@ function ttlFor(collection: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
|
* 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,
|
collection: string,
|
||||||
key: string,
|
key: string,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
): 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);
|
const hit = cache.get(key);
|
||||||
if (hit && hit.expires > Date.now()) return hit.value as T;
|
if (hit && hit.expires > Date.now()) return hit.value as T;
|
||||||
const pending = inflight.get(key) as Promise<T> | undefined;
|
const pending = inflight.get(key) as Promise<T> | undefined;
|
||||||
@@ -117,7 +124,9 @@ async function cached<T>(
|
|||||||
export function invalidateCollection(collection: string): number {
|
export function invalidateCollection(collection: string): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const k of cache.keys()) {
|
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);
|
cache.delete(k);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
@@ -160,14 +169,35 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
|||||||
export async function getPages(): Promise<PageEntry[]> {
|
export async function getPages(): Promise<PageEntry[]> {
|
||||||
return cached("page", "page:list:", async () => {
|
return cached("page", "page:list:", async () => {
|
||||||
const base = getBaseUrl();
|
const base = getBaseUrl();
|
||||||
const res = await cmsFetch(`${base}/api/content/page`);
|
// Alle Seiten holen — CMS-Default-per_page ist 50, die Site hat aber mehr.
|
||||||
if (!res.ok) {
|
// Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
|
||||||
throw new Error(
|
// getPageBySlug für Seiten jenseits der ersten Page → 404.
|
||||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
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) {
|
||||||
const data = (await res.json()) as PageListResponse;
|
throw new Error(
|
||||||
return data.items ?? [];
|
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +352,38 @@ function normalizePageSlug(s: string | undefined): string {
|
|||||||
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
|
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).
|
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
|
||||||
*/
|
*/
|
||||||
@@ -354,6 +416,18 @@ export async function getPageBySlug(
|
|||||||
res = await trySlug("/" + normSlug);
|
res = await trySlug("/" + normSlug);
|
||||||
}
|
}
|
||||||
if (res.status === 404) {
|
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 items = await getPages();
|
||||||
const match = items.find(
|
const match = items.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -625,6 +699,42 @@ export async function getPostBySlug(
|
|||||||
return cached("post", key, 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. */
|
/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
|
||||||
export async function getPostSlugs(): Promise<string[]> {
|
export async function getPostSlugs(): Promise<string[]> {
|
||||||
const items = await getPosts();
|
const items = await getPosts();
|
||||||
@@ -710,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). */
|
/** 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"];
|
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];
|
||||||
|
|
||||||
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
|
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
|
||||||
@@ -786,6 +947,58 @@ export async function getTranslationBundleBySlug(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */
|
||||||
export type CalendarEntry = components["schemas"]["calendar"];
|
export type CalendarEntry = components["schemas"]["calendar"];
|
||||||
|
|
||||||
@@ -879,3 +1092,20 @@ export async function getCommentCounts(
|
|||||||
return {};
|
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,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>
|
</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">
|
<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}
|
{label}
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-4">
|
<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">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
type Variant = "subtle" | "solid";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children,
|
children,
|
||||||
color,
|
color,
|
||||||
|
variant = "subtle",
|
||||||
|
uppercase = false,
|
||||||
class: className = "",
|
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) {
|
switch (c) {
|
||||||
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
|
case "green": return "bg-wald-50 text-wald-700 border-wald-200";
|
||||||
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
|
case "blue": return "bg-himmel-50 text-himmel-700 border-himmel-200";
|
||||||
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
|
case "amber": return "bg-amber-50 text-amber-700 border-amber-200";
|
||||||
default: return "bg-stein-100 text-stein-600 border border-stein-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>
|
</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?.()}
|
{@render children?.()}
|
||||||
</span>
|
</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}
|
||||||
@@ -5,8 +5,23 @@
|
|||||||
import EventBadges from "./EventBadges.svelte";
|
import EventBadges from "./EventBadges.svelte";
|
||||||
import Tag from "./Tag.svelte";
|
import Tag from "./Tag.svelte";
|
||||||
import Pagination from "./Pagination.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 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 { t as tStatic, T } from "$lib/translations";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +49,6 @@
|
|||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
posts = [],
|
posts = [],
|
||||||
tags = [],
|
tags = [],
|
||||||
@@ -47,6 +60,7 @@
|
|||||||
translations = {},
|
translations = {},
|
||||||
upcomingEvents = [],
|
upcomingEvents = [],
|
||||||
searchIndex = null,
|
searchIndex = null,
|
||||||
|
sort = "newest",
|
||||||
}: {
|
}: {
|
||||||
posts: PostEntry[];
|
posts: PostEntry[];
|
||||||
tags: TagEntry[];
|
tags: TagEntry[];
|
||||||
@@ -56,12 +70,81 @@
|
|||||||
totalPosts?: number;
|
totalPosts?: number;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
translations?: Translations | null;
|
translations?: Translations | null;
|
||||||
upcomingEvents?: EventPost[];
|
upcomingEvents?: PostEntry[];
|
||||||
searchIndex?: SearchIndexEntry[] | null;
|
searchIndex?: SearchIndexEntry[] | null;
|
||||||
|
sort?: PostSortOrder;
|
||||||
} = $props();
|
} = $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 query = $state("");
|
||||||
let year = $state<string>("");
|
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 normalizedQuery = $derived(query.trim().toLowerCase());
|
||||||
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
|
||||||
|
|
||||||
@@ -130,41 +213,23 @@
|
|||||||
<div class="blog-overview">
|
<div class="blog-overview">
|
||||||
{#if searchIndex}
|
{#if searchIndex}
|
||||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<label class="relative block w-full sm:max-w-sm">
|
<SearchField
|
||||||
<span class="sr-only">{t(T.blog_search_label)}</span>
|
bind:value={query}
|
||||||
<span
|
placeholder={t(T.blog_search_placeholder)}
|
||||||
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500"
|
ariaLabel={t(T.blog_search_label)}
|
||||||
>
|
class="w-full sm:max-w-sm"
|
||||||
<Icon icon="mdi:magnify" class="h-4 w-4" />
|
/>
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder={t(T.blog_search_placeholder)}
|
|
||||||
class="w-full rounded-sm border border-zinc-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
{#if query}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (query = "")}
|
|
||||||
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
|
|
||||||
aria-label={t(T.blog_search_clear)}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:close" class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
{#if availableYears.length > 0}
|
{#if availableYears.length > 0}
|
||||||
<label class="relative inline-flex items-center">
|
<label class="relative inline-flex items-center">
|
||||||
<span class="sr-only">{t(T.blog_year_label)}</span>
|
<span class="sr-only">{t(T.blog_year_label)}</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500"
|
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:calendar" class="h-4 w-4" />
|
<Icon icon="lucide:calendar" class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
bind:value={year}
|
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>
|
<option value="">{t(T.blog_year_all)}</option>
|
||||||
{#each availableYears as y}
|
{#each availableYears as y}
|
||||||
@@ -172,9 +237,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-zinc-500"
|
class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-stein-500"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:chevron-down" class="h-4 w-4" />
|
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -182,37 +247,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isFiltering && upcomingEvents.length > 0}
|
{#if !isFiltering && upcomingEvents.length > 0}
|
||||||
<section
|
<Card variant="callout" class="not-prose mb-6">
|
||||||
class="not-prose mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4"
|
|
||||||
>
|
|
||||||
<h2
|
<h2
|
||||||
class="mb-3 flex items-center gap-2 text-sm font-semibold tracking-wide text-zinc-700"
|
class="mb-1 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:calendar-clock" class="size-7 shrink-0" />
|
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
|
||||||
{t(T.blog_upcoming_events)}
|
{t(T.blog_upcoming_events)}
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="not-prose list-none space-y-2 pl-0">
|
<ul class="not-prose list-none space-y-2 pl-0">
|
||||||
{#each upcomingEvents as ev}
|
{#each upcomingEvents as ev}
|
||||||
|
{@const evInfo = getPostEventInfo(ev)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={postHref(ev)}
|
href={postHref(ev)}
|
||||||
class="group flex flex-wrap items-center gap-2 no-underline"
|
class="group flex flex-wrap items-center gap-2 no-underline"
|
||||||
>
|
>
|
||||||
<EventBadges eventDate={ev.eventDate} />
|
<EventBadges eventDate={evInfo.eventDate} />
|
||||||
<span
|
<span
|
||||||
class="text-sm text-zinc-900 group-hover:text-zinc-950"
|
class="text-sm text-stein-900 group-hover:text-stein-900"
|
||||||
>{ev.headline}</span
|
>{ev.headline}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isFiltering}
|
{#if isFiltering}
|
||||||
<div
|
<div
|
||||||
class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600"
|
class="mb-3 flex flex-wrap items-center gap-2 text-xs text-stein-600"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{t(T.blog_results_count, { count: filteredResults.length })}
|
{t(T.blog_results_count, { count: filteredResults.length })}
|
||||||
@@ -225,7 +289,7 @@
|
|||||||
query = "";
|
query = "";
|
||||||
year = "";
|
year = "";
|
||||||
}}
|
}}
|
||||||
class="text-zinc-500 underline hover:text-zinc-900"
|
class="text-stein-500 underline hover:text-stein-900"
|
||||||
>{t(T.blog_filter_reset)}</button
|
>{t(T.blog_filter_reset)}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,13 +298,14 @@
|
|||||||
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{#each filteredResults as hit}
|
{#each filteredResults as hit}
|
||||||
<a
|
<Card
|
||||||
|
variant="tile"
|
||||||
|
layout="post"
|
||||||
href={searchResultHref(hit.slug)}
|
href={searchResultHref(hit.slug)}
|
||||||
class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all"
|
|
||||||
>
|
>
|
||||||
{#if hit.image}
|
{#if hit.image}
|
||||||
<div
|
<div
|
||||||
class="aspect-3/2 w-full overflow-hidden bg-gray-100"
|
class="aspect-3/2 w-full overflow-hidden bg-stein-100"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={hit.image}
|
src={hit.image}
|
||||||
@@ -255,23 +320,23 @@
|
|||||||
{hit.headline}
|
{hit.headline}
|
||||||
</h3>
|
</h3>
|
||||||
{#if hit.excerpt}
|
{#if hit.excerpt}
|
||||||
<p class="text-sm text-zinc-700 line-clamp-3">
|
<p class="text-sm text-stein-700 line-clamp-3">
|
||||||
{hit.excerpt}
|
{hit.excerpt}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600"
|
class="rounded-lg border border-stein-200 bg-white p-6 text-center text-sm text-stein-600"
|
||||||
>
|
>
|
||||||
{t(T.blog_no_results)}
|
{t(T.blog_no_results)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#if tags.length > 0}
|
{#if topicTags.length > 0}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-2"
|
class="flex flex-wrap gap-2"
|
||||||
@@ -284,7 +349,7 @@
|
|||||||
variant={activeTag ? "blue" : "green"}
|
variant={activeTag ? "blue" : "green"}
|
||||||
active={!activeTag}
|
active={!activeTag}
|
||||||
/>
|
/>
|
||||||
{#each tags as tag}
|
{#each visibleTags as tag}
|
||||||
{@const slug = tag._slug ?? ""}
|
{@const slug = tag._slug ?? ""}
|
||||||
<Tag
|
<Tag
|
||||||
label={tag.name}
|
label={tag.name}
|
||||||
@@ -297,14 +362,164 @@
|
|||||||
: tag.color?.trim() || null}
|
: tag.color?.trim() || null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Pagination {currentPage} {totalPages} {basePath} {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">
|
<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
|
<PostCard
|
||||||
{post}
|
{post}
|
||||||
href={postHref(post)}
|
href={postHref(post)}
|
||||||
@@ -315,36 +530,42 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
{#if currentPage === 1 && !activeTag}
|
||||||
<Pagination {currentPage} {totalPages} {basePath} {activeTag} />
|
<div
|
||||||
<div class="grow"></div>
|
class="not-prose my-6 rounded-lg bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
|
||||||
<div class="bg-white rounded-md font-thin">
|
>
|
||||||
<div class="inline-flex text-xs p-2">
|
<NewsletterInlineFormComponent />
|
||||||
{#if totalCount > 0}
|
|
||||||
{t(T.blog_count, {
|
|
||||||
visible: posts.length,
|
|
||||||
total: totalCount,
|
|
||||||
current: currentPage,
|
|
||||||
totalPages,
|
|
||||||
})}
|
|
||||||
{:else}
|
|
||||||
{t(T.blog_count, {
|
|
||||||
visible: 0,
|
|
||||||
total: 0,
|
|
||||||
current: currentPage,
|
|
||||||
totalPages,
|
|
||||||
})}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
|
||||||
|
<div class="grow"></div>
|
||||||
|
<Badge>
|
||||||
|
{#if totalCount > 0}
|
||||||
|
{t(T.blog_count, {
|
||||||
|
visible: posts.length,
|
||||||
|
total: totalCount,
|
||||||
|
current: currentPage,
|
||||||
|
totalPages,
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
{t(T.blog_count, {
|
||||||
|
visible: 0,
|
||||||
|
total: 0,
|
||||||
|
current: currentPage,
|
||||||
|
totalPages,
|
||||||
|
})}
|
||||||
|
{/if}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-zinc-600">
|
<div class="mt-5">
|
||||||
<a
|
<a
|
||||||
href="/posts/rss.xml"
|
href="/posts/rss.xml"
|
||||||
class="inline-flex items-center gap-1 hover:underline"
|
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="mdi:rss" class="h-3.5 w-3.5" />
|
<Icon icon="lucide:rss" class="size-4 text-wald-600" />
|
||||||
{t(T.blog_rss_subscribe)}
|
{t(T.blog_rss_subscribe)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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.
|
* tile + post: PostCard – Shell ohne Padding, Medien bündig, Text mit p-4 im Kind.
|
||||||
* callout: CTA; layout wird ignoriert.
|
* 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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
tile: "",
|
tile: "",
|
||||||
@@ -24,20 +24,17 @@
|
|||||||
{
|
{
|
||||||
variant: "tile",
|
variant: "tile",
|
||||||
layout: "stack",
|
layout: "stack",
|
||||||
class:
|
class: "card-surface flex flex-col gap-2 p-4 text-stein-900",
|
||||||
"flex flex-col gap-2 border border-gray-200 bg-white p-4 text-slate-950",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
variant: "tile",
|
variant: "tile",
|
||||||
layout: "inline",
|
layout: "inline",
|
||||||
class:
|
class: "card-surface flex flex-row items-start gap-3 p-4 text-stein-900",
|
||||||
"flex flex-row items-start gap-3 border border-gray-200 bg-white p-4 text-slate-950",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
variant: "tile",
|
variant: "tile",
|
||||||
layout: "post",
|
layout: "post",
|
||||||
class:
|
class: "card-surface flex flex-col gap-0 p-0 text-stein-900",
|
||||||
"flex flex-col gap-0 border border-gray-200 bg-white p-0 text-slate-950",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultVariants: { variant: "tile", layout: "stack" },
|
defaultVariants: { variant: "tile", layout: "stack" },
|
||||||
@@ -46,8 +43,8 @@
|
|||||||
const cardAsLink = cva("", {
|
const cardAsLink = cva("", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
callout: "cursor-pointer transition-all no-underline text-inherit",
|
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
|
||||||
tile: "transition-all no-underline hover:border-gray-300",
|
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" },
|
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>
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import { useTranslate, T } from "$lib/translations";
|
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 {
|
let {
|
||||||
pageId,
|
pageId,
|
||||||
@@ -305,42 +308,39 @@
|
|||||||
bind:value={editBody}
|
bind:value={editBody}
|
||||||
use:autosize
|
use:autosize
|
||||||
onkeydown={submitOnCmdEnter}
|
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"
|
rows="2"
|
||||||
maxlength={bodyMax}
|
maxlength={bodyMax}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="mt-1 flex items-center gap-3 text-xs">
|
<div class="mt-1 flex items-center gap-3 text-xs">
|
||||||
<button
|
<Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
|
||||||
type="submit"
|
|
||||||
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
|
|
||||||
>{t(T.comments_save)}</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEdit}
|
onclick={cancelEdit}
|
||||||
class="text-zinc-500 hover:text-zinc-900"
|
class="text-stein-500 hover:text-stein-900"
|
||||||
>{t(T.comments_cancel)}</button>
|
>{t(T.comments_cancel)}</button>
|
||||||
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
<span class="ml-auto text-stein-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{:else}
|
{: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}
|
{#if c.can_edit}
|
||||||
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => startEdit(c)}
|
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" />
|
<Icon icon="lucide:pencil" class="size-3" />
|
||||||
{t(T.comments_edit)}
|
{t(T.comments_edit)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => deleteComment(c.id)}
|
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" />
|
<Icon icon="lucide:trash-2" class="size-3" />
|
||||||
{t(T.comments_delete)}
|
{t(T.comments_delete)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,23 +350,23 @@
|
|||||||
|
|
||||||
{#snippet meta(c: PublicComment)}
|
{#snippet meta(c: PublicComment)}
|
||||||
<div class="flex flex-wrap items-baseline gap-2 text-sm">
|
<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}
|
{#if c.can_edit}
|
||||||
<span class="text-[10px] uppercase tracking-wide text-zinc-400">{t(T.comments_self_marker)}</span>
|
<span class="text-[10px] uppercase tracking-wide text-stein-400">{t(T.comments_self_marker)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<time
|
<time
|
||||||
class="text-zinc-500 text-xs"
|
class="text-stein-500 text-xs"
|
||||||
datetime={c.created_at}
|
datetime={c.created_at}
|
||||||
title={fmtAbs(c.created_at)}
|
title={fmtAbs(c.created_at)}
|
||||||
>{fmtRel(c.created_at)}</time>
|
>{fmtRel(c.created_at)}</time>
|
||||||
{#if c.updated_at}
|
{#if c.updated_at}
|
||||||
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
|
<span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status === "pending"}
|
{#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">
|
<Badge color="amber" uppercase class="ml-auto inline-flex items-center gap-1">
|
||||||
<Icon icon="mdi:clock-outline" class="size-3" />
|
<Icon icon="lucide:clock" class="size-3" />
|
||||||
{t(T.comments_pending_short)}
|
{t(T.comments_pending_short)}
|
||||||
</span>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -376,33 +376,33 @@
|
|||||||
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
|
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
|
||||||
{#each Array(2) as _, i (i)}
|
{#each Array(2) as _, i (i)}
|
||||||
<li class="flex gap-3 list-none!">
|
<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="flex-1 space-y-2">
|
||||||
<div class="h-3 w-32 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-zinc-200 rounded 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-zinc-200 rounded animate-pulse"></div>
|
<div class="h-3 w-3/4 bg-stein-200 rounded-xs animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if loadError}
|
{: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">
|
<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="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
|
<Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium">{t(T.comments_load_error_title)}</p>
|
<p class="font-medium">{t(T.comments_load_error_title)}</p>
|
||||||
<p class="mt-1 text-xs text-red-700">{loadError}</p>
|
<p class="mt-1 text-xs text-fire-700">{loadError}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={loadComments}
|
onclick={loadComments}
|
||||||
class="text-xs text-red-800 underline hover:no-underline"
|
class="text-xs text-fire-800 underline hover:no-underline"
|
||||||
>{t(T.comments_retry)}</button>
|
>{t(T.comments_retry)}</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if topLevel.length === 0}
|
{#if topLevel.length === 0}
|
||||||
<div class="flex flex-col items-center gap-2 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-3">
|
<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="mdi:comment-text-outline" class="size-10 text-zinc-300 shrink-0" />
|
<Icon icon="lucide:message-square-text" class="size-5 text-stein-300 shrink-0" />
|
||||||
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
|
<p class="text-sm font-semibold text-stein-300">{t(T.comments_empty)}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-5 mb-8 list-none p-0">
|
<ul class="space-y-5 mb-8 list-none p-0">
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
{@const replies = repliesOf(c.id)}
|
{@const replies = repliesOf(c.id)}
|
||||||
<li
|
<li
|
||||||
class="group flex gap-3 list-none! {c.status === 'pending'
|
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)}
|
{@render avatar(c.author)}
|
||||||
@@ -422,9 +422,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
|
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
|
{replyTo === c.id
|
||||||
? t(T.comments_cancel)
|
? t(T.comments_cancel)
|
||||||
: replies.length === 0
|
: replies.length === 0
|
||||||
@@ -437,13 +437,12 @@
|
|||||||
|
|
||||||
{#if replyTo === c.id}
|
{#if replyTo === c.id}
|
||||||
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
|
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
|
||||||
bind:value={replyAuthor}
|
bind:value={replyAuthor}
|
||||||
placeholder={t(T.comments_name_placeholder)}
|
placeholder={t(T.comments_name_placeholder)}
|
||||||
|
label=""
|
||||||
required
|
required
|
||||||
maxlength="80"
|
size="sm"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={replyBody}
|
bind:value={replyBody}
|
||||||
@@ -453,26 +452,29 @@
|
|||||||
required
|
required
|
||||||
rows="2"
|
rows="2"
|
||||||
maxlength={bodyMax}
|
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>
|
></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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
variant="primary"
|
||||||
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
|
size="sm"
|
||||||
>{t(T.comments_reply_send)}</button>
|
loading={submitting}
|
||||||
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if replies.length > 0}
|
{#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)}
|
{#each replies as r (r.id)}
|
||||||
<li
|
<li
|
||||||
class="group flex gap-3 list-none! {r.status === 'pending'
|
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")}
|
{@render avatar(r.author, "sm")}
|
||||||
@@ -490,15 +492,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
|
<form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3">
|
||||||
<h3 class="font-medium text-sm text-zinc-900">{t(T.comments_form_title)}</h3>
|
<h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
|
||||||
bind:value={author}
|
bind:value={author}
|
||||||
placeholder={t(T.comments_name_placeholder)}
|
placeholder={t(T.comments_name_placeholder)}
|
||||||
|
label=""
|
||||||
required
|
required
|
||||||
maxlength="80"
|
size="sm"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={body}
|
bind:value={body}
|
||||||
@@ -508,7 +509,7 @@
|
|||||||
required
|
required
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength={bodyMax}
|
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>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -519,26 +520,27 @@
|
|||||||
class="absolute left-[-9999px]"
|
class="absolute left-[-9999px]"
|
||||||
/>
|
/>
|
||||||
{#if submitError}
|
{#if submitError}
|
||||||
<p class="text-red-700 text-xs">{submitError}</p>
|
<p class="text-fire-700 text-xs">{submitError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !body.trim() || !author.trim()}
|
variant="primary"
|
||||||
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"
|
loading={submitting}
|
||||||
>
|
disabled={!body.trim() || !author.trim()}
|
||||||
{submitting ? t(T.comments_sending) : t(T.comments_send)}
|
label={submitting ? t(T.comments_sending) : t(T.comments_send)}
|
||||||
</button>
|
class="text-sm"
|
||||||
|
/>
|
||||||
{#if requireApproval}
|
{#if requireApproval}
|
||||||
<span class="text-zinc-500 inline-flex items-center gap-1">
|
<span class="text-stein-500 inline-flex items-center gap-1">
|
||||||
<Icon icon="mdi:shield-check-outline" class="size-3.5" />
|
<Icon icon="lucide:shield-check" class="size-3.5" />
|
||||||
{t(T.comments_approval_hint)}
|
{t(T.comments_approval_hint)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{#if totalPending > 0}
|
{#if totalPending > 0}
|
||||||
<p class="text-xs text-zinc-500 italic">
|
<p class="text-xs text-stein-500 italic">
|
||||||
{totalPending === 1
|
{totalPending === 1
|
||||||
? t(T.comments_pending_summary_one)
|
? t(T.comments_pending_summary_one)
|
||||||
: t(T.comments_pending_summary_other, { count: totalPending })}
|
: t(T.comments_pending_summary_other, { count: totalPending })}
|
||||||
|
|||||||
@@ -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,47 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
|
import BlockRenderer from "./BlockRenderer.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 OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
|
||||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
|
||||||
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
|
||||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
||||||
import type { BlockLayout } from "$lib/block-layout";
|
import type { BlockLayout } from "$lib/block-layout";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import type {
|
import type { RowContentLayout, ResolvedBlock } from "$lib/block-types";
|
||||||
RowContentLayout,
|
|
||||||
ResolvedBlock,
|
|
||||||
MarkdownBlockData,
|
|
||||||
HeadlineBlockData,
|
|
||||||
HtmlBlockData,
|
|
||||||
IframeBlockData,
|
|
||||||
ImageBlockData,
|
|
||||||
ImageGalleryBlockData,
|
|
||||||
FilesBlockData,
|
|
||||||
YoutubeVideoBlockData,
|
|
||||||
YoutubeVideoGalleryBlockData,
|
|
||||||
QuoteBlockData,
|
|
||||||
QuoteCarouselBlockData,
|
|
||||||
LinkListBlockData,
|
|
||||||
PostOverviewBlockData,
|
|
||||||
SearchableTextBlockData,
|
|
||||||
CalendarBlockData,
|
|
||||||
OrganisationsBlockData,
|
|
||||||
OpnFormBlockData,
|
|
||||||
DeadlineBannerBlockData,
|
|
||||||
} from "$lib/block-types";
|
|
||||||
|
|
||||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||||
|
|
||||||
@@ -103,68 +65,25 @@
|
|||||||
>
|
>
|
||||||
{#each row.content as item}
|
{#each row.content as item}
|
||||||
{#if isResolvedBlock(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) === "youtube_video_gallery"}
|
|
||||||
<YoutubeVideoGalleryBlock block={item as YoutubeVideoGalleryBlockData} />
|
|
||||||
{: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)}
|
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
|
||||||
{@const rowBlockType = blockType(item)}
|
{@const type = blockType(item)}
|
||||||
<div class="col-span-12">
|
<div class="col-span-12">
|
||||||
<div
|
<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'
|
? 'bg-wald-50'
|
||||||
: '[background:var(--color-container-breakout)]'}"
|
: '[background:var(--color-container-breakout)]'}"
|
||||||
>
|
>
|
||||||
{#if rowBlockType === 'deadline_banner'}
|
{#if type === 'deadline_banner'}
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="container-custom py-8">
|
<div class="container-custom py-8">
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/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' },
|
||||||
|
};
|
||||||
@@ -28,20 +28,20 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#if dateLabel}
|
{#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 no-underline! [text-decoration:none]">
|
<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="mdi:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
<Icon icon="lucide:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||||
{dateLabel}
|
{dateLabel}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if locationText}
|
{#if locationText}
|
||||||
{#if locationHref}
|
{#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!">
|
<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="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||||
{locationText}
|
<span class="truncate max-w-[16rem]">{locationText}</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{: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 no-underline! [text-decoration:none]">
|
<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="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
|
||||||
{locationText}
|
<span class="truncate max-w-[16rem]">{locationText}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/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; }}
|
onclick={() => { overlayVisible = false; }}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') 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">
|
<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="mdi:cursor-default-click" class="size-4 shrink-0" aria-hidden="true" />
|
<Icon icon="lucide:mouse-pointer-click" class="size-4 shrink-0" aria-hidden="true" />
|
||||||
{t(translations, T.post_map_activate)}
|
{t(translations, T.post_map_activate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
rel="noopener noreferrer"
|
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"
|
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)}
|
{t(translations, T.post_map_open_osm)}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
rel="noopener noreferrer"
|
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"
|
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)}
|
{locationText ?? t(translations, T.post_map_open_osm)}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,12 +8,26 @@
|
|||||||
id?: string;
|
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) {
|
function t(key: string) {
|
||||||
return tStatic(translations ?? null, key);
|
return tStatic(translations ?? null, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const hasRowContent = $derived(
|
const hasRowContent = $derived(
|
||||||
!!footer &&
|
!!footer &&
|
||||||
((footer.row1Content?.length ?? 0) > 0 ||
|
((footer.row1Content?.length ?? 0) > 0 ||
|
||||||
@@ -24,21 +38,37 @@
|
|||||||
|
|
||||||
{#if footer}
|
{#if footer}
|
||||||
<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}
|
data-footer-id={footer.id}
|
||||||
>
|
>
|
||||||
<div class="container-custom">
|
<div class="container-custom">
|
||||||
{#if hasRowContent}
|
{#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} />
|
<ContentRows layout={footer} translations={translations} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
<p class="text-sm text-white/80 text-center">
|
|
||||||
|
<!-- 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) !== T.footer_copyright
|
||||||
? t(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>
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -47,33 +77,58 @@
|
|||||||
.content-footer :global(.content) {
|
.content-footer :global(.content) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.content-footer :global(.content-row) {
|
/* Footer nutzt jetzt das normale 12-Spalten-Grid (grid grid-cols-12 aus
|
||||||
display: flex;
|
ContentRows) + per-Block layout (mobile/tablet/desktop + desktopStart).
|
||||||
flex-wrap: wrap;
|
Kein Flex-Override mehr — Spaltigkeit kommt aus dem CMS-layout. */
|
||||||
gap: 2rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.content-footer :global(.content-row > *) {
|
|
||||||
flex: 1 1 200px;
|
|
||||||
grid-column: auto !important;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.content-footer :global(.content-row + .content-row) {
|
.content-footer :global(.content-row + .content-row) {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding-top: 1.5rem;
|
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) {
|
.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) {
|
.content-footer :global(a:hover) {
|
||||||
color: rgb(255 255 255);
|
color: var(--color-stein-0);
|
||||||
}
|
}
|
||||||
.content-footer :global(h3) {
|
.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(.markdown),
|
||||||
.content-footer :global(.prose) {
|
.content-footer :global(.prose) {
|
||||||
color: rgb(212 212 216);
|
color: var(--color-stein-200);
|
||||||
}
|
}
|
||||||
</style>
|
</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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -19,6 +19,12 @@
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
href: string;
|
href: string;
|
||||||
label: 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 {
|
interface SocialLink {
|
||||||
@@ -46,6 +52,11 @@
|
|||||||
}
|
}
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let searchOpen = $state(false);
|
let searchOpen = $state(false);
|
||||||
|
let openSubmenu = $state<string | null>(null);
|
||||||
|
|
||||||
|
function toggleSubmenu(label: string) {
|
||||||
|
openSubmenu = openSubmenu === label ? null : label;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
if (menuOpen) {
|
if (menuOpen) {
|
||||||
@@ -53,6 +64,11 @@
|
|||||||
} else {
|
} else {
|
||||||
searchOpen = false;
|
searchOpen = false;
|
||||||
menuOpen = true;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +116,10 @@
|
|||||||
|
|
||||||
<header
|
<header
|
||||||
id="horizontal-navigation"
|
id="horizontal-navigation"
|
||||||
class="sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
|
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
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
aria-label={t(T.site_name_fallback)}
|
aria-label={t(T.site_name_fallback)}
|
||||||
@@ -110,11 +127,11 @@
|
|||||||
>
|
>
|
||||||
{#if logoSvgHtml}
|
{#if logoSvgHtml}
|
||||||
<span
|
<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"
|
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
|
aria-hidden="true">{@html logoSvgHtml}</span
|
||||||
>
|
>
|
||||||
{:else if logoUrl && logoUrl.trim() !== ""}
|
{: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}
|
{:else}
|
||||||
<span>{t(T.site_name_fallback)}</span>
|
<span>{t(T.site_name_fallback)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -126,63 +143,111 @@
|
|||||||
aria-label={t(T.header_nav_aria)}
|
aria-label={t(T.header_nav_aria)}
|
||||||
>
|
>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a
|
{@const active = isActiveTree(link, $page.url.pathname)}
|
||||||
href={link.href}
|
{#if link.children && link.children.length > 0}
|
||||||
class="hover:animate-pulse transition-all text-[.7rem]"
|
<div class="relative group inline-flex items-center">
|
||||||
class:font-bold={isActiveLink(
|
{#if link.href && link.href !== "#"}
|
||||||
link.href,
|
<a
|
||||||
$page.url.pathname,
|
href={link.href}
|
||||||
)}
|
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||||
class:underline={isActiveLink(
|
aria-haspopup="menu"
|
||||||
link.href,
|
aria-expanded="false"
|
||||||
$page.url.pathname,
|
>
|
||||||
)}
|
{link.label}
|
||||||
aria-current={isActiveLink(link.href, $page.url.pathname)
|
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||||
? "page"
|
</a>
|
||||||
: undefined}
|
{:else}
|
||||||
>
|
<button
|
||||||
{link.label}
|
type="button"
|
||||||
</a>
|
class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
|
||||||
{/each}
|
aria-haspopup="menu"
|
||||||
<div class="w-1 h-4"></div>
|
aria-expanded="false"
|
||||||
{#each socialLinks as social}
|
>
|
||||||
<a
|
{link.label}
|
||||||
href={social.href}
|
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
|
||||||
target="_blank"
|
</button>
|
||||||
rel="noopener noreferrer"
|
{/if}
|
||||||
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center"
|
<div
|
||||||
aria-label={social.label || t(T.header_social_aria)}
|
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"
|
||||||
<Icon icon={social.icon} aria-hidden="true" />
|
>
|
||||||
</a>
|
<ul class="bg-stein-0 shadow-lg rounded border border-stein-200 py-1 text-stein-800">
|
||||||
{/each}
|
{#each link.children as child}
|
||||||
<button
|
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
|
||||||
type="button"
|
<li role="none">
|
||||||
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
|
<a
|
||||||
aria-label={searchOpen
|
role="menuitem"
|
||||||
? t(T.header_search_close)
|
href={child.href}
|
||||||
: t(T.header_search_open)}
|
class="block px-3 py-1.5 text-xs hover:bg-wald-50 {cActive ? 'font-bold text-wald-700' : ''}"
|
||||||
aria-expanded={searchOpen}
|
aria-current={cActive ? "page" : undefined}
|
||||||
aria-controls="search-panel"
|
>
|
||||||
onclick={() => (searchOpen = !searchOpen)}
|
{child.label}
|
||||||
>
|
</a>
|
||||||
{#if searchOpen}
|
</li>
|
||||||
<Icon
|
{/each}
|
||||||
icon="mdi:close"
|
</ul>
|
||||||
class="text-error!"
|
</div>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="mdi:magnify" aria-hidden="true" />
|
<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}
|
{/if}
|
||||||
</button>
|
{/each}
|
||||||
|
<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={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} 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="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="lucide:x"
|
||||||
|
class="text-sm text-error!"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon icon="lucide:search" class="text-sm" aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
|
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
|
||||||
<div class="lg:hidden flex items-center gap-1">
|
<div class="lg:hidden flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="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
|
aria-label={searchOpen
|
||||||
? t(T.header_search_close)
|
? t(T.header_search_close)
|
||||||
: t(T.header_search_open)}
|
: t(T.header_search_open)}
|
||||||
@@ -192,21 +257,21 @@
|
|||||||
>
|
>
|
||||||
{#if searchOpen}
|
{#if searchOpen}
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:close"
|
icon="lucide:x"
|
||||||
class="text-2xl text-error!"
|
class="text-sm text-error!"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:magnify"
|
icon="lucide:search"
|
||||||
class="text-2xl"
|
class="text-sm"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
|
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
|
aria-label={menuOpen
|
||||||
? t(T.header_menu_close)
|
? t(T.header_menu_close)
|
||||||
: t(T.header_menu_open)}
|
: t(T.header_menu_open)}
|
||||||
@@ -220,14 +285,11 @@
|
|||||||
: t(T.header_menu_open)}</span
|
: t(T.header_menu_open)}</span
|
||||||
>
|
>
|
||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<Icon
|
<Icon icon="lucide:x" class="text-sm" aria-hidden="true" />
|
||||||
icon="mdi:close"
|
|
||||||
class="text-2xl"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
|
<Icon icon="lucide:menu" class="text-sm" aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
|
<span class="text-[.7rem] leading-none" aria-hidden="true">{menuOpen ? "Schließen" : "Menü"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,58 +300,109 @@
|
|||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<div
|
<div
|
||||||
id="mobile-nav"
|
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"
|
role="dialog"
|
||||||
aria-label="Navigation"
|
aria-label="Navigation"
|
||||||
in:slide={{ duration: 200 }}
|
in:slide={{ duration: 200 }}
|
||||||
out:slide={{ duration: 150 }}
|
out:slide={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
<nav
|
<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)}
|
aria-label={t(T.header_nav_aria)}
|
||||||
>
|
>
|
||||||
{#each links as link}
|
<div class="grid grid-cols-2 gap-2.5 pb-4 mb-2 border-b border-stein-0/10">
|
||||||
<a
|
<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}>
|
||||||
href={link.href}
|
<Icon icon="lucide:heart-handshake" class="size-4 shrink-0" aria-hidden="true" />
|
||||||
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
|
Mitmachen
|
||||||
class:font-bold={isActiveLink(
|
|
||||||
link.href,
|
|
||||||
$page.url.pathname,
|
|
||||||
)}
|
|
||||||
class:underline={isActiveLink(
|
|
||||||
link.href,
|
|
||||||
$page.url.pathname,
|
|
||||||
)}
|
|
||||||
aria-current={isActiveLink(
|
|
||||||
link.href,
|
|
||||||
$page.url.pathname,
|
|
||||||
)
|
|
||||||
? "page"
|
|
||||||
: undefined}
|
|
||||||
onclick={closeMenu}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
</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-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}
|
{/each}
|
||||||
{#if socialLinks.length > 0}
|
{#if socialLinks.length > 0}
|
||||||
<div
|
<div class="flex flex-col gap-1 pt-2 mt-2 border-t border-stein-0/10">
|
||||||
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
|
|
||||||
>
|
|
||||||
{#each socialLinks as social}
|
{#each socialLinks as social}
|
||||||
|
{@const isInternal = social.href.startsWith("/")}
|
||||||
|
{@const sActive =
|
||||||
|
isInternal &&
|
||||||
|
isActiveLink(social.href, $page.url.pathname)}
|
||||||
<a
|
<a
|
||||||
href={social.href}
|
href={social.href}
|
||||||
target="_blank"
|
target={isInternal ? undefined : "_blank"}
|
||||||
rel="noopener noreferrer"
|
rel={isInternal
|
||||||
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
|
? 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 ||
|
aria-label={social.label ||
|
||||||
t(T.header_social_aria)}
|
t(T.header_social_aria)}
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon={social.icon}
|
icon={social.icon}
|
||||||
class="text-[1.5rem]"
|
class="text-sm"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
{#if social.label}
|
||||||
|
<span>{social.label}</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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,
|
totalPages = 1,
|
||||||
basePath = "/posts",
|
basePath = "/posts",
|
||||||
activeTag = null,
|
activeTag = null,
|
||||||
|
sort = "newest",
|
||||||
}: {
|
}: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
activeTag?: string | null;
|
activeTag?: string | null;
|
||||||
|
sort?: "newest" | "oldest";
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
|
||||||
|
|
||||||
function tagHref(tagSlug: string | null): string {
|
function tagHref(tagSlug: string | null): string {
|
||||||
if (!tagSlug) return `${basePath}/`;
|
if (!tagSlug) return `${basePath}/${query}`;
|
||||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`;
|
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHref(page: number): string {
|
function pageHref(page: number): string {
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
const tagPart = activeTag
|
const tagPart = activeTag
|
||||||
? `/tag/${encodeURIComponent(activeTag)}`
|
? `/tag/${encodeURIComponent(activeTag)}`
|
||||||
: "";
|
: "";
|
||||||
return `${basePath}${tagPart}/page/${page}/`;
|
return `${basePath}${tagPart}/page/${page}/${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showPagination = $derived(totalPages > 1);
|
const showPagination = $derived(totalPages > 1);
|
||||||
@@ -45,32 +49,40 @@
|
|||||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||||
});
|
});
|
||||||
|
|
||||||
const paginationLinkClass =
|
const baseShape =
|
||||||
" 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";
|
"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>
|
</script>
|
||||||
|
|
||||||
{#if showPagination}
|
{#if showPagination}
|
||||||
<div class="inline-flex py-4">
|
<nav class="inline-flex py-4 items-center" aria-label="Seitennavigation">
|
||||||
<div class="flex">
|
<div class="flex items-center">
|
||||||
{#if hasPrev}
|
{#if hasPrev}
|
||||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
|
<a href={pageHref(prevPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_prev)}>
|
||||||
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
|
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
|
||||||
|
<span class="hidden sm:inline">{t(T.pagination_prev)}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#each pageNumbers as num}
|
{#each pageNumbers as num}
|
||||||
<a
|
<a
|
||||||
href={pageHref(num)}
|
href={pageHref(num)}
|
||||||
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
|
class="{baseShape} {numberShape} {num === currentPage ? activeColors : inactiveColors}"
|
||||||
aria-current={num === currentPage ? 'page' : undefined}
|
aria-current={num === currentPage ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{num}
|
{num}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if hasNext}
|
{#if hasNext}
|
||||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
|
<a href={pageHref(nextPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_next)}>
|
||||||
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
|
<span class="hidden sm:inline">{t(T.pagination_next)}</span>
|
||||||
|
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import { useTranslate, T } from "$lib/translations";
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
import QrButton from "$lib/components/QrButton.svelte";
|
||||||
|
|
||||||
|
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
@@ -11,6 +15,7 @@
|
|||||||
commentPageId = null,
|
commentPageId = null,
|
||||||
commentEnvironment,
|
commentEnvironment,
|
||||||
commentTargetId = "comments-section",
|
commentTargetId = "comments-section",
|
||||||
|
socialSlug = null,
|
||||||
class: cls = "",
|
class: cls = "",
|
||||||
}: {
|
}: {
|
||||||
/** Absolute URL of the page being shared. */
|
/** Absolute URL of the page being shared. */
|
||||||
@@ -25,9 +30,22 @@
|
|||||||
commentEnvironment?: string;
|
commentEnvironment?: string;
|
||||||
/** DOM id of the comment accordion (used by the count-badge click). */
|
/** DOM id of the comment accordion (used by the count-badge click). */
|
||||||
commentTargetId?: string;
|
commentTargetId?: string;
|
||||||
|
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
|
||||||
|
socialSlug?: string | null;
|
||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $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();
|
const t = useTranslate();
|
||||||
|
|
||||||
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
||||||
@@ -138,6 +156,73 @@
|
|||||||
function print() {
|
function print() {
|
||||||
window.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>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -154,54 +239,104 @@
|
|||||||
onclick={onCommentClick}
|
onclick={onCommentClick}
|
||||||
aria-label={t(T.comments_jump_to)}
|
aria-label={t(T.comments_jump_to)}
|
||||||
title={t(T.comments_jump_to)}
|
title={t(T.comments_jump_to)}
|
||||||
class="no-underline! inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700! hover:bg-zinc-50 hover:border-zinc-300 transition-colors"
|
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="mdi:comment-outline" class="size-3.5" />
|
<Icon icon="lucide:message-circle" class="size-3.5" />
|
||||||
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="inline-flex items-center rounded-full border border-zinc-200 bg-white overflow-hidden divide-x divide-zinc-200 print:hidden">
|
<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}
|
{#if canShare}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={share}
|
onclick={share}
|
||||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
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)}
|
aria-label={t(T.post_action_share)}
|
||||||
title={t(T.post_action_share)}
|
title={t(T.post_action_share)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
|
icon={shared ? "lucide:check" : "lucide:share-2"}
|
||||||
class="size-3.5"
|
class="size-3.5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={copyLink}
|
onclick={copyLink}
|
||||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
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)}
|
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)}
|
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
||||||
>
|
>
|
||||||
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={print}
|
onclick={print}
|
||||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
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)}
|
aria-label={t(T.post_action_print)}
|
||||||
title={t(T.post_action_print)}
|
title={t(T.post_action_print)}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
<Icon icon="lucide:printer" class="size-3.5" />
|
||||||
</button>
|
</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>
|
</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}
|
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-500"
|
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 })}
|
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:clock-outline" class="size-3.5" />
|
<Icon icon="lucide:clock" class="size-3.5" />
|
||||||
<span class="tabular-nums"
|
<span class="tabular-nums"
|
||||||
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
import EventBadges from "./EventBadges.svelte";
|
import EventBadges from "./EventBadges.svelte";
|
||||||
import RustyImage from "./RustyImage.svelte";
|
import RustyImage from "./RustyImage.svelte";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
|
import Badge from "./Badge.svelte";
|
||||||
|
import { getPostEventInfo } from "$lib/blog-utils";
|
||||||
import { useTranslate, T } from "$lib/translations";
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
import { marked } from "$lib/markdown-safe";
|
||||||
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
@@ -21,27 +24,23 @@
|
|||||||
href,
|
href,
|
||||||
tagBase = "/posts/tag",
|
tagBase = "/posts/tag",
|
||||||
commentCount = null,
|
commentCount = null,
|
||||||
|
class: className = "",
|
||||||
}: {
|
}: {
|
||||||
post: PostWithImage;
|
post: PostWithImage;
|
||||||
href: string;
|
href: string;
|
||||||
tagBase?: string;
|
tagBase?: string;
|
||||||
/** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */
|
/** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */
|
||||||
commentCount?: number | null;
|
commentCount?: number | null;
|
||||||
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const rawImg = $derived(post._rawImageUrl);
|
const rawImg = $derived(post._rawImageUrl);
|
||||||
const focal = $derived(post._imageFocal ?? null);
|
const focal = $derived(post._imageFocal ?? null);
|
||||||
const fallbackImg = $derived(post._resolvedImageUrl);
|
const fallbackImg = $derived(post._resolvedImageUrl);
|
||||||
|
|
||||||
type PostWithEvent = PostWithImage & {
|
const eventInfo = $derived(getPostEventInfo(post));
|
||||||
isEvent?: boolean;
|
const eventDate = $derived(eventInfo.eventDate);
|
||||||
eventDate?: string;
|
const locationText = $derived(eventInfo.eventLocation?.text ?? null);
|
||||||
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 tagSlugs = $derived(
|
const tagSlugs = $derived(
|
||||||
(post.postTag ?? []).map((t) =>
|
(post.postTag ?? []).map((t) =>
|
||||||
@@ -50,16 +49,26 @@
|
|||||||
);
|
);
|
||||||
const hasTerminTag = $derived(tagSlugs.includes("tag-termin"));
|
const hasTerminTag = $derived(tagSlugs.includes("tag-termin"));
|
||||||
const isPastEvent = $derived(
|
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>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
{href}
|
{href}
|
||||||
layout="post"
|
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}
|
{#if rawImg}
|
||||||
<RustyImage
|
<RustyImage
|
||||||
src={rawImg}
|
src={rawImg}
|
||||||
@@ -79,36 +88,50 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if eventDate}
|
{#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} />
|
<EventBadges {eventDate} {locationText} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isPastEvent}
|
{#if isPastEvent}
|
||||||
<div class="absolute top-2 right-2">
|
<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">
|
<Badge variant="solid" color="slate" uppercase class="shadow-sm">Vergangen</Badge>
|
||||||
Vergangen
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if commentCount != null}
|
<div class="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||||
<div class="absolute top-2 left-2">
|
{#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
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-zinc-700 shadow-sm"
|
|
||||||
aria-label={t(T.comments_count_aria, { count: commentCount })}
|
aria-label={t(T.comments_count_aria, { count: commentCount })}
|
||||||
title={t(T.comments_count_aria, { count: commentCount })}
|
title={t(T.comments_count_aria, { count: commentCount })}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:comment-outline" class="size-3" />
|
<Badge
|
||||||
{commentCount}
|
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>
|
</span>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col justify-start p-4">
|
<div class="flex-1 flex flex-col justify-start p-4">
|
||||||
<PostMeta
|
<PostMeta
|
||||||
@@ -122,7 +145,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
{#if post.subheadline}
|
{#if post.subheadline}
|
||||||
<div class="text-xs line-clamp-2 mb-1">
|
<div class="text-xs line-clamp-2 mb-1">
|
||||||
{post.subheadline}
|
{@html marked.parseInline(post.subheadline)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if post.excerpt}
|
{#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">
|
<script lang="ts">
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
|
import SearchField from "$lib/ui/SearchField.svelte";
|
||||||
|
|
||||||
type SearchHit = {
|
type SearchHit = {
|
||||||
type: "page" | "post";
|
type: "page" | "post";
|
||||||
@@ -25,18 +27,72 @@
|
|||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let inputEl = $state<HTMLInputElement | null>(null);
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const MAX_RESULTS = 40;
|
||||||
|
|
||||||
const normalizedQuery = $derived(query.trim().toLowerCase());
|
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[]>(() => {
|
const results = $derived.by<SearchHit[]>(() => {
|
||||||
if (!allHits || normalizedQuery.length < 2) return [];
|
if (!allHits || normalizedQuery.length < 2) return [];
|
||||||
const q = normalizedQuery;
|
const scored: { hit: SearchHit; score: number }[] = [];
|
||||||
return allHits
|
for (const h of allHits) {
|
||||||
.filter((h) => {
|
const title = h.title.toLowerCase();
|
||||||
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase();
|
const subtitle = h.subtitle.toLowerCase();
|
||||||
return hay.includes(q);
|
const excerpt = h.excerpt.toLowerCase();
|
||||||
})
|
const slug = h.slug.toLowerCase();
|
||||||
.slice(0, 40);
|
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() {
|
async function loadIndex() {
|
||||||
if (allHits || loading) return;
|
if (allHits || loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -70,7 +126,26 @@
|
|||||||
void loadIndex();
|
void loadIndex();
|
||||||
tick().then(() => inputEl?.focus());
|
tick().then(() => inputEl?.focus());
|
||||||
const onKey = (e: KeyboardEvent) => {
|
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);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
@@ -80,79 +155,74 @@
|
|||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
id="search-panel"
|
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"
|
role="region"
|
||||||
aria-label="Suche"
|
aria-label="Suche"
|
||||||
in:slide={{ duration: 200 }}
|
in:slide={{ duration: 200 }}
|
||||||
out:slide={{ duration: 150 }}
|
out:slide={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]">
|
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
||||||
<label class="relative block max-w-xl mx-auto">
|
<SearchField
|
||||||
<span class="sr-only">Suche</span>
|
bind:value={query}
|
||||||
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-zinc-500">
|
bind:inputEl
|
||||||
<Icon icon="mdi:magnify" class="h-5 w-5" />
|
tone="dark"
|
||||||
</span>
|
placeholder="Seiten und Beiträge durchsuchen…"
|
||||||
<input
|
class="max-w-xl mx-auto"
|
||||||
bind:this={inputEl}
|
/>
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Seiten und Beiträge durchsuchen…"
|
|
||||||
class="w-full rounded-sm border border-zinc-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
{#if query}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (query = "")}
|
|
||||||
aria-label="Eingabe leeren"
|
|
||||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:close" class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="max-w-xl mx-auto mt-4 text-sm">
|
<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}
|
{#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}
|
{: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}
|
{: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}
|
{: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}
|
{:else}
|
||||||
<ul class="divide-y divide-zinc-200">
|
<p class="text-stein-400 text-xs mb-2">
|
||||||
{#each results as hit}
|
{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>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
bind:this={itemEls[i]}
|
||||||
href={hit.href}
|
href={hit.href}
|
||||||
onclick={closeSearch}
|
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}
|
{#if hit.image}
|
||||||
<img
|
<img
|
||||||
src={hit.image}
|
src={hit.image}
|
||||||
alt=""
|
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"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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">
|
<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" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" />
|
<Icon icon={hit.type === "post" ? "lucide:file-text" : "lucide:file"} class="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<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>
|
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
|
||||||
{#if hit.isEvent && hit.eventDate}
|
{#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}
|
{:else if hit.created}
|
||||||
<span>{formatDate(hit.created)}</span>
|
<span>{formatDate(hit.created)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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 =
|
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(() => {
|
const variantClass = $derived.by(() => {
|
||||||
if (customColor?.trim()) {
|
if (customColor?.trim()) {
|
||||||
return "";
|
return "";
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
if (variant === "white")
|
if (variant === "white")
|
||||||
return "bg-stein-100 text-stein-800";
|
return "bg-stein-100 text-stein-800";
|
||||||
if (variant === "green") return "bg-wald-100 text-wald-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 === "inactive") return "bg-stein-200 text-stein-500";
|
||||||
if (variant === "date") return "bg-wald-50 text-wald-800";
|
if (variant === "date") return "bg-wald-50 text-wald-800";
|
||||||
return "bg-stein-200 text-stein-500";
|
return "bg-stein-200 text-stein-500";
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
const c = customColor?.trim();
|
const c = customColor?.trim();
|
||||||
if (!c) return "";
|
if (!c) return "";
|
||||||
const light = isLightBackground(c);
|
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};`;
|
return `background-color: ${c}; color: ${fg};`;
|
||||||
});
|
});
|
||||||
const interactiveClass = $derived.by(() =>
|
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' },
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||||
|
|
||||||
interface ResponsiveBannerImage {
|
interface ResponsiveBannerImage {
|
||||||
sources: { type: string; srcset: string }[];
|
sources: { type: string; srcset: string; media?: string }[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -68,6 +68,13 @@
|
|||||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
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(
|
const hasResponsive = $derived(
|
||||||
responsive != null && responsive.fallback.trim().length > 0,
|
responsive != null && responsive.fallback.trim().length > 0,
|
||||||
);
|
);
|
||||||
@@ -79,47 +86,34 @@
|
|||||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||||
const altText = $derived(banner?.headline ?? headline ?? "");
|
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||||
|
|
||||||
let imgEl = $state<HTMLImageElement | null>(null);
|
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
|
||||||
let imgLoaded = $state(false);
|
// 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.
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
{#if hasImage}
|
{#if hasImage}
|
||||||
<div
|
<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}
|
style={wrapperStyle}
|
||||||
>
|
>
|
||||||
{#if hasResponsive && responsive}
|
{#if hasResponsive && responsive}
|
||||||
{#key responsive.fallback}
|
{#key responsive.fallback}
|
||||||
<picture class="block w-full max-w-[1920px] mx-auto">
|
<picture class="block w-full max-w-[1920px] mx-auto">
|
||||||
{#each responsive.sources as s (s.type)}
|
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
|
||||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
|
||||||
{/each}
|
{/each}
|
||||||
<img
|
<img
|
||||||
src={responsive.fallback}
|
src={responsive.fallback}
|
||||||
alt={altText}
|
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="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||||
class:opacity-0={!imgLoaded}
|
|
||||||
class:opacity-100={imgLoaded}
|
|
||||||
width={responsive.width}
|
width={responsive.width}
|
||||||
height={responsive.height}
|
height={responsive.height}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
bind:this={imgEl}
|
|
||||||
onload={() => (imgLoaded = true)}
|
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
{/key}
|
{/key}
|
||||||
@@ -128,44 +122,57 @@
|
|||||||
<img
|
<img
|
||||||
src={resolvedImages[0]}
|
src={resolvedImages[0]}
|
||||||
alt={altText}
|
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="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"
|
||||||
class:opacity-0={!imgLoaded}
|
|
||||||
class:opacity-100={imgLoaded}
|
|
||||||
width={1920}
|
width={1920}
|
||||||
height={600}
|
height={600}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
onload={() => (imgLoaded = true)}
|
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasPageTitle}
|
{#if hasPageTitle}
|
||||||
<div class="absolute inset-0 flex items-center justify-start ">
|
<!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
|
||||||
<div class="container-custom mx-auto px-6 relative">
|
<div
|
||||||
{#if headlineHtml}
|
class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
|
||||||
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
|
aria-hidden="true"
|
||||||
{/if}
|
></div>
|
||||||
<div></div>
|
<div class="absolute inset-0 flex items-center justify-start">
|
||||||
{#if subheadlineHtml}
|
<div class="container-custom mx-auto px-6">
|
||||||
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2>
|
<div class="max-w-xl text-white">
|
||||||
{/if}
|
{#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="text-4xl font-bold leading-[1.05] tracking-tight text-white! drop-shadow-sm lg:text-5xl">
|
||||||
|
{@html headlineHtml}
|
||||||
|
</h1>
|
||||||
|
{/if}
|
||||||
|
{#if subheadlineHtml}
|
||||||
|
<h2 class="mt-4 text-xl! font-normal! leading-snug text-stein-100! lg:text-2xl!">
|
||||||
|
{@html subheadlineHtml}
|
||||||
|
</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if hasPageTitle}
|
{: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}
|
{#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}
|
||||||
{#if subheadlineHtml}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasText}
|
{#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="container mx-auto">
|
||||||
<div class="text-sm font-medium">{banner!.text}</div>
|
<div class="text-sm font-medium">{banner!.text}</div>
|
||||||
</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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ContactCard from "$lib/components/ContactCard.svelte";
|
||||||
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
|
import type { ContactsBlockData, ContactEntry } from "$lib/block-types";
|
||||||
|
|
||||||
|
let { block }: { block: ContactsBlockData } = $props();
|
||||||
|
|
||||||
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
|
|
||||||
|
const contacts = $derived(
|
||||||
|
(block.contacts ?? []).filter((c): c is ContactEntry => typeof c === "object" && c !== null)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if contacts.length > 0}
|
||||||
|
<div class="{layoutClasses}" data-block="ContactsBlock" data-block-type="contacts_block">
|
||||||
|
{#if block.headline}
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-stein-700">{block.headline}</h3>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="flex snap-x snap-mandatory gap-3 overflow-x-auto px-px pt-1 pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{#each contacts as contact}
|
||||||
|
<div class="w-[calc(100%-2rem)] min-w-[220px] max-w-[320px] shrink-0 snap-start sm:w-60 lg:w-72">
|
||||||
|
<ContactCard {contact} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||||
|
import DeadlineBannerBlock from './DeadlineBannerBlock.svelte';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'CMS-Blocks/DeadlineBanner',
|
||||||
|
component: DeadlineBannerBlock,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: { block: { control: 'object' } },
|
||||||
|
} satisfies Meta<typeof DeadlineBannerBlock>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Dringend: Story = {
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
mode: 'manual',
|
||||||
|
label: 'Einwendungsfrist',
|
||||||
|
text: 'Noch Zeit für Ihre Stellungnahme',
|
||||||
|
date: '2026-09-30',
|
||||||
|
variant: 'urgent',
|
||||||
|
showCountdown: true,
|
||||||
|
link: { url: '/mitmachen', linkName: 'Jetzt einwenden', newTab: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Info: Story = {
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
mode: 'manual',
|
||||||
|
label: 'Hinweis',
|
||||||
|
text: 'Nächste Infoveranstaltung',
|
||||||
|
date: '2026-08-15',
|
||||||
|
variant: 'info',
|
||||||
|
showCountdown: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,149 +1,149 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { DeadlineBannerBlockData } from "$lib/block-types";
|
import type { DeadlineBannerBlockData } from "$lib/block-types";
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import "$lib/iconify-offline";
|
|
||||||
|
|
||||||
let { block }: { block: DeadlineBannerBlockData } = $props();
|
let { block }: { block: DeadlineBannerBlockData } = $props();
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const variant = $derived(block.variant ?? "accent");
|
const showCountdown = $derived(block.showCountdown !== false);
|
||||||
const showCountdown = $derived(block.showCountdown !== false);
|
|
||||||
|
|
||||||
type ResolvedItem = {
|
// Look wie AnnouncementBanner (kompakte, zentrierte Leiste) — nur farbig.
|
||||||
title?: string;
|
// "urgent" = rot, damit die Frist wirkt.
|
||||||
terminZeit?: string;
|
const barClass = $derived.by(() => {
|
||||||
description?: string;
|
switch (block.variant) {
|
||||||
link?: DeadlineBannerBlockData["link"];
|
case "accent":
|
||||||
};
|
return "bg-himmel-600";
|
||||||
|
case "info":
|
||||||
const autoItem = $derived.by((): ResolvedItem | null => {
|
return "bg-wald-600";
|
||||||
if (block.mode !== "auto") return null;
|
case "urgent":
|
||||||
const now = Date.now();
|
return "bg-fire-600";
|
||||||
const items = (block.items ?? [])
|
default:
|
||||||
.filter(
|
return "bg-stein-700";
|
||||||
(x): x is ResolvedItem =>
|
}
|
||||||
typeof x === "object" && x !== null && "terminZeit" in x,
|
|
||||||
)
|
|
||||||
.map((x): { x: ResolvedItem; ts: number } => ({ x, ts: Date.parse(x.terminZeit ?? "") }))
|
|
||||||
.filter(({ ts }) => !isNaN(ts) && ts >= now)
|
|
||||||
.sort((a, b) => a.ts - b.ts);
|
|
||||||
return items[0]?.x ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = $derived(
|
|
||||||
block.mode === "auto" ? autoItem?.title ?? "" : block.text ?? "",
|
|
||||||
);
|
|
||||||
const dateIso = $derived(
|
|
||||||
block.mode === "auto" ? autoItem?.terminZeit ?? "" : block.date ?? "",
|
|
||||||
);
|
|
||||||
const linkValue = $derived(
|
|
||||||
block.mode === "auto" ? autoItem?.link : block.link,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateObj = $derived.by(() => {
|
|
||||||
if (!dateIso) return null;
|
|
||||||
const d = new Date(dateIso);
|
|
||||||
return isNaN(d.getTime()) ? null : d;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateStr = $derived.by(() => {
|
|
||||||
if (!dateObj) return "";
|
|
||||||
return dateObj.toLocaleDateString("de-DE", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const daysLeft = $derived.by(() => {
|
type ResolvedItem = {
|
||||||
if (!dateObj) return null;
|
title?: string;
|
||||||
const ms = dateObj.getTime() - Date.now();
|
terminZeit?: string;
|
||||||
if (ms < 0) return null;
|
description?: string;
|
||||||
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
link?: DeadlineBannerBlockData["link"];
|
||||||
});
|
};
|
||||||
|
|
||||||
const countdownStr = $derived.by(() => {
|
const autoItem = $derived.by((): ResolvedItem | null => {
|
||||||
if (!showCountdown || daysLeft == null) return "";
|
if (block.mode !== "auto") return null;
|
||||||
if (daysLeft === 0) return "Heute";
|
const now = Date.now();
|
||||||
if (daysLeft === 1) return "Morgen";
|
const items = (block.items ?? [])
|
||||||
return `Noch ${daysLeft} Tage`;
|
.filter(
|
||||||
});
|
(x): x is ResolvedItem =>
|
||||||
|
typeof x === "object" && x !== null && "terminZeit" in x,
|
||||||
|
)
|
||||||
|
.map((x): { x: ResolvedItem; ts: number } => ({
|
||||||
|
x,
|
||||||
|
ts: Date.parse(x.terminZeit ?? ""),
|
||||||
|
}))
|
||||||
|
.filter(({ ts }) => !isNaN(ts) && ts >= now)
|
||||||
|
.sort((a, b) => a.ts - b.ts);
|
||||||
|
return items[0]?.x ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
function getHref(link: DeadlineBannerBlockData["link"]): string {
|
const text = $derived(
|
||||||
if (typeof link === "string" && link) return link;
|
block.mode === "auto" ? (autoItem?.title ?? "") : (block.text ?? ""),
|
||||||
if (link && typeof link === "object" && link.url) return link.url;
|
);
|
||||||
return "";
|
const dateIso = $derived(
|
||||||
}
|
block.mode === "auto"
|
||||||
const href = $derived(getHref(linkValue));
|
? (autoItem?.terminZeit ?? "")
|
||||||
const newTab = $derived(
|
: (block.date ?? ""),
|
||||||
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
|
);
|
||||||
);
|
const linkValue = $derived(
|
||||||
|
block.mode === "auto" ? autoItem?.link : block.link,
|
||||||
|
);
|
||||||
|
|
||||||
const variantClasses = $derived(
|
const dateObj = $derived.by(() => {
|
||||||
variant === "urgent"
|
if (!dateIso) return null;
|
||||||
? "bg-amber-100 text-amber-900"
|
const d = new Date(dateIso);
|
||||||
: variant === "info"
|
return isNaN(d.getTime()) ? null : d;
|
||||||
? "bg-sky-100 text-sky-900"
|
});
|
||||||
: "bg-wald-500 text-wald-50",
|
|
||||||
);
|
const dateStr = $derived.by(() => {
|
||||||
const iconName = $derived(
|
if (!dateObj) return "";
|
||||||
variant === "urgent" ? "mdi:alert-circle" : "mdi:calendar-clock",
|
return dateObj.toLocaleDateString("de-DE", {
|
||||||
);
|
day: "numeric",
|
||||||
const hasContent = $derived(!!(text || dateStr || countdownStr));
|
month: "long",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Kalendertage zwischen heute und Ziel — über Mitternacht-zu-
|
||||||
|
* Mitternacht-Diff, nicht roh per ms. Sonst zählt z. B. 30 h vor
|
||||||
|
* Termin als „2 Tage" obwohl der Termin morgen ist. */
|
||||||
|
const daysLeft = $derived.by(() => {
|
||||||
|
if (!dateObj) return null;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const target = new Date(dateObj);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const diffDays = Math.round(
|
||||||
|
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
if (diffDays < 0) return null;
|
||||||
|
return diffDays;
|
||||||
|
});
|
||||||
|
|
||||||
|
const countdownStr = $derived.by(() => {
|
||||||
|
if (!showCountdown || daysLeft == null) return "";
|
||||||
|
if (daysLeft === 0) return "Heute";
|
||||||
|
if (daysLeft === 1) return "Morgen";
|
||||||
|
return `Noch ${daysLeft} Tage`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getHref(link: DeadlineBannerBlockData["link"]): string {
|
||||||
|
if (typeof link === "string" && link) return link;
|
||||||
|
if (link && typeof link === "object" && link.url) return link.url;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const href = $derived(getHref(linkValue));
|
||||||
|
const newTab = $derived(
|
||||||
|
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
|
||||||
|
);
|
||||||
|
const ctaLabel = $derived(
|
||||||
|
linkValue && typeof linkValue === "object" && linkValue.linkName
|
||||||
|
? linkValue.linkName
|
||||||
|
: "Mehr erfahren",
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentence = $derived.by(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (text) parts.push(text);
|
||||||
|
// Datum nur im Auto-Modus anhängen — im Manual-Text steht es meist schon.
|
||||||
|
if (dateStr && block.mode === "auto") parts.push(`am ${dateStr}`);
|
||||||
|
return parts.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasContent = $derived(!!(sentence || countdownStr));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if hasContent}
|
{#if hasContent}
|
||||||
<div
|
<div
|
||||||
class="deadline-banner {layoutClasses}"
|
class="deadline-banner {layoutClasses} w-full {barClass} text-white print:hidden"
|
||||||
data-block-type="deadline_banner"
|
data-block="DeadlineBanner" data-block-type="deadline_banner"
|
||||||
data-block-slug={block._slug}
|
data-block-slug={block._slug}
|
||||||
>
|
role="note"
|
||||||
{#snippet inner()}
|
style="font-size: 0.7rem;"
|
||||||
<div class="container-custom flex flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-1 px-4 py-2 text-xs sm:text-sm">
|
>
|
||||||
{#if dateStr}
|
<div class="container-custom flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 py-2 text-center">
|
||||||
<span class="inline-flex items-center rounded-md bg-white/50 text-wald-900 px-2 py-0.5 text-sm sm:text-base font-bold whitespace-nowrap shrink-0">
|
{#if sentence}
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<span>{sentence}</span>
|
||||||
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
|
|
||||||
{dateStr}
|
|
||||||
</span>
|
|
||||||
{#if countdownStr}
|
|
||||||
<span
|
|
||||||
class="ml-0 inline-flex min-w-0 max-w-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding,border-color] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:border-wald-900 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:border-wald-900 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
|
|
||||||
>
|
|
||||||
{countdownStr}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="inline-flex items-center gap-0 shrink-0">
|
|
||||||
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
|
|
||||||
{#if countdownStr}
|
{#if countdownStr}
|
||||||
<span
|
<span class="whitespace-nowrap rounded-full bg-black/20 px-2 py-0.5 font-bold">{countdownStr}</span>
|
||||||
class="ml-0 inline-flex min-w-0 max-w-0 shrink-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-full px-0 py-0.5 text-xs font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
|
|
||||||
>
|
|
||||||
{countdownStr}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
{#if href}
|
||||||
{/if}
|
<a
|
||||||
{#if text}
|
{href}
|
||||||
<span class="min-w-0 wrap-break-word line-clamp-2 sm:line-clamp-1 font-semibold text-shadow-sm">{text}</span>
|
target={newTab ? "_blank" : undefined}
|
||||||
{/if}
|
rel={newTab ? "noopener noreferrer" : undefined}
|
||||||
</div>
|
class="whitespace-nowrap font-semibold underline underline-offset-2 hover:no-underline"
|
||||||
{/snippet}
|
>{ctaLabel} →</a>
|
||||||
{#if href}
|
{/if}
|
||||||
<a
|
</div>
|
||||||
href={href}
|
</div>
|
||||||
target={newTab ? "_blank" : undefined}
|
|
||||||
rel={newTab ? "noopener noreferrer" : undefined}
|
|
||||||
class="group block no-underline text-inherit hover:no-underline transition-all hover:brightness-95 {variantClasses}"
|
|
||||||
>
|
|
||||||
{@render inner()}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="group {variantClasses}">
|
|
||||||
{@render inner()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||||
|
import EventDateBox from './EventDateBox.svelte';
|
||||||
|
|
||||||
|
// Kleiner Datums-Kasten (Tag/Monat), genutzt von Kalender-Karten. Kein CMS-Block
|
||||||
|
// → als Molecule geführt. Nimmt echte Date-Objekte.
|
||||||
|
const meta = {
|
||||||
|
title: 'Molecules/EventDateBox',
|
||||||
|
component: EventDateBox,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof EventDateBox>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Eintagestermin: Story = {
|
||||||
|
args: { start: new Date('2026-08-15T18:00:00') },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mehrtagestermin: Story = {
|
||||||
|
args: {
|
||||||
|
start: new Date('2026-08-15T10:00:00'),
|
||||||
|
end: new Date('2026-08-17T16:00:00'),
|
||||||
|
multiDay: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Datums-Kachel für Kalender-Einträge.
|
||||||
|
*
|
||||||
|
* - Einzeltermin: Wochentag · großer Tag · Monat
|
||||||
|
* - Mehrtägig: Wochentag-Range · Tag-Range · Monat
|
||||||
|
* (gleiche Höhe, Box wächst nur in der Breite)
|
||||||
|
*
|
||||||
|
* Bewusst feste rem-Schriftgrößen + `size-*` (mobil etwas kleiner als
|
||||||
|
* Desktop). Keine fluid/calc-Skalierung → kein Overflow, identisch
|
||||||
|
* berechenbar auf allen Geräten.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
start,
|
||||||
|
end = null,
|
||||||
|
multiDay = false,
|
||||||
|
}: { start: Date; end?: Date | null; multiDay?: boolean } = $props();
|
||||||
|
|
||||||
|
const isRange = $derived(multiDay && !!end);
|
||||||
|
|
||||||
|
const weekday = (d: Date) =>
|
||||||
|
d.toLocaleDateString("de-DE", { weekday: "short" }).replace(/\.$/, "");
|
||||||
|
const month = (d: Date) =>
|
||||||
|
d.toLocaleDateString("de-DE", { month: "short" });
|
||||||
|
|
||||||
|
// YYYY-MM-DD aus lokalen Feldern (kein UTC-Shift) für <time datetime>.
|
||||||
|
const isoDate = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const fullDate = (d: Date) =>
|
||||||
|
d.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ariaLabel = $derived(
|
||||||
|
isRange && end
|
||||||
|
? `${fullDate(start)} bis ${fullDate(end)}`
|
||||||
|
: fullDate(start),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<time
|
||||||
|
datetime={isoDate(start)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
class="shrink-0 select-none aspect-square size-18 rounded-sm rounded-br-4xl bg-wald-200 text-wald-500 flex justify-center items-center shadow"
|
||||||
|
>
|
||||||
|
<div class="text-center font-black leading-normal">
|
||||||
|
<div class="text-[10px] uppercase tracking-wider">
|
||||||
|
{#if isRange && end}{weekday(start)}–{weekday(end)}{:else}{weekday(
|
||||||
|
start,
|
||||||
|
)}{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-[20px] leading-5">
|
||||||
|
{#if isRange && end}{start.getDate()}–{end.getDate()}{:else}{start.getDate()}{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider">
|
||||||
|
{month(start)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</time>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||||
|
import FilesBlock from './FilesBlock.svelte';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'CMS-Blocks/Files',
|
||||||
|
component: FilesBlock,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: { block: { control: 'object' } },
|
||||||
|
} satisfies Meta<typeof FilesBlock>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Standard: Story = {
|
||||||
|
// addon-a11y's Live-Scan crasht in Docs-Mode bei echten fokussierbaren
|
||||||
|
// <a>-Elementen (react-aria Cross-Iframe-Bug, Storybook 10). Files ist die
|
||||||
|
// einzige Story mit echten Links → hier deaktivieren.
|
||||||
|
parameters: { a11y: { test: 'off' } },
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
number: '08',
|
||||||
|
kicker: 'Downloads',
|
||||||
|
headline: 'Dokumente',
|
||||||
|
action: { url: '/downloads', linkName: 'Alle Downloads' },
|
||||||
|
description: 'Downloads zum Verfahren',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
src: 'https://cms.pm86.de/api/assets/files/beispiel.pdf?_environment=windwiderstand',
|
||||||
|
title: 'Einwendung Vorlage',
|
||||||
|
description: 'Zum Ausfüllen und Einreichen beim zuständigen Landratsamt',
|
||||||
|
mime: 'application/pdf',
|
||||||
|
size: 248000,
|
||||||
|
filename: 'einwendung-vorlage.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://cms.pm86.de/api/assets/files/karte.pdf?_environment=windwiderstand',
|
||||||
|
title: 'Übersichtskarte',
|
||||||
|
mime: 'application/pdf',
|
||||||
|
size: 1240000,
|
||||||
|
filename: 'uebersichtskarte.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
|
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
||||||
import type { FilesBlockData } from "$lib/block-types";
|
import type { FilesBlockData } from "$lib/block-types";
|
||||||
import {
|
import {
|
||||||
extractCmsFileField,
|
extractCmsFileField,
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
} from "$lib/rusty-file";
|
} from "$lib/rusty-file";
|
||||||
|
|
||||||
let { block }: { block: FilesBlockData } = $props();
|
let { block }: { block: FilesBlockData } = $props();
|
||||||
|
const actionRef = $derived(
|
||||||
|
block.action && typeof block.action === "object" ? block.action : null,
|
||||||
|
);
|
||||||
|
|
||||||
const layoutClasses = $derived(
|
const layoutClasses = $derived(
|
||||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||||
@@ -21,66 +25,9 @@
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
sizeLabel: string | null;
|
sizeLabel: string | null;
|
||||||
ext: string | null;
|
extLabel: string;
|
||||||
iconName: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function iconForExt(ext: string | null): string {
|
|
||||||
switch (ext) {
|
|
||||||
case "pdf":
|
|
||||||
return "mdi:file-pdf-box";
|
|
||||||
case "doc":
|
|
||||||
case "docx":
|
|
||||||
case "rtf":
|
|
||||||
case "odt":
|
|
||||||
return "mdi:file-word-box";
|
|
||||||
case "xls":
|
|
||||||
case "xlsx":
|
|
||||||
case "ods":
|
|
||||||
case "csv":
|
|
||||||
case "tsv":
|
|
||||||
return "mdi:file-excel-box";
|
|
||||||
case "ppt":
|
|
||||||
case "pptx":
|
|
||||||
return "mdi:file-powerpoint-box";
|
|
||||||
case "zip":
|
|
||||||
case "tar":
|
|
||||||
case "gz":
|
|
||||||
case "7z":
|
|
||||||
return "mdi:folder-zip";
|
|
||||||
case "jpg":
|
|
||||||
case "jpeg":
|
|
||||||
case "png":
|
|
||||||
case "webp":
|
|
||||||
case "gif":
|
|
||||||
case "svg":
|
|
||||||
return "mdi:file-image";
|
|
||||||
case "txt":
|
|
||||||
case "md":
|
|
||||||
return "mdi:file-document-outline";
|
|
||||||
case "json":
|
|
||||||
return "mdi:code-json";
|
|
||||||
case "xml":
|
|
||||||
case "html":
|
|
||||||
case "htm":
|
|
||||||
return "mdi:xml";
|
|
||||||
case "py":
|
|
||||||
return "mdi:language-python";
|
|
||||||
case "js":
|
|
||||||
case "ts":
|
|
||||||
case "tsx":
|
|
||||||
case "jsx":
|
|
||||||
return "mdi:language-javascript";
|
|
||||||
case "rs":
|
|
||||||
return "mdi:language-rust";
|
|
||||||
case "sh":
|
|
||||||
case "bash":
|
|
||||||
return "mdi:console";
|
|
||||||
default:
|
|
||||||
return "mdi:file-outline";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = $derived(
|
const resolved = $derived(
|
||||||
((block.items ?? []) as unknown[])
|
((block.items ?? []) as unknown[])
|
||||||
.map((raw): Resolved | null => {
|
.map((raw): Resolved | null => {
|
||||||
@@ -98,45 +45,61 @@
|
|||||||
label,
|
label,
|
||||||
description: field.description,
|
description: field.description,
|
||||||
sizeLabel: formatFileSize(field.size),
|
sizeLabel: formatFileSize(field.size),
|
||||||
ext,
|
extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
|
||||||
iconName: iconForExt(ext),
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((r): r is Resolved => r !== null),
|
.filter((r): r is Resolved => r !== null),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="files-block {layoutClasses}" data-block-type="files" data-block-slug={block._slug}>
|
<div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}>
|
||||||
{#if block.headline}
|
{#if block.headline}
|
||||||
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3>
|
<div class="mb-3">
|
||||||
{/if}
|
<SectionHeader
|
||||||
{#if block.description}
|
number={block.number}
|
||||||
<p class="mb-3 text-sm text-zinc-700">{block.description}</p>
|
kicker={block.kicker}
|
||||||
|
title={block.headline}
|
||||||
|
subtitle={block.description}
|
||||||
|
tag="h3"
|
||||||
|
actionLabel={actionRef?.linkName}
|
||||||
|
actionHref={actionRef?.url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if block.description}
|
||||||
|
<p class="mb-3 text-sm text-stein-700">{block.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if resolved.length === 0}
|
{#if resolved.length === 0}
|
||||||
<p class="text-sm text-zinc-500">Keine Dateien verfügbar.</p>
|
<p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-2 list-none! p-0 m-0! ml-0">
|
<ul class="flex flex-col gap-3 list-none! p-0 m-0! ml-0">
|
||||||
{#each resolved as f}
|
{#each resolved as f}
|
||||||
<li class="list-none! ml-0">
|
<li class="list-none! ml-0">
|
||||||
<a
|
<a
|
||||||
href={f.href}
|
href={f.href}
|
||||||
class="group flex items-start gap-3 rounded-md border border-zinc-200 bg-white px-3 py-2 hover:border-zinc-400 hover:bg-zinc-50 transition-colors"
|
class="group card-hover-lift flex items-center gap-3 rounded-xl border border-stein-200 bg-white px-3.5 py-2.5 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Icon icon={f.iconName} class="size-6 shrink-0 text-zinc-600" aria-hidden="true" />
|
<span
|
||||||
<span class="flex flex-col min-w-0">
|
class="shrink-0 size-9 flex items-center justify-center rounded-lg bg-fire-50 text-[10px] font-bold tracking-wide text-fire-700"
|
||||||
<span class="font-medium text-zinc-900 group-hover:underline truncate">{f.label}</span>
|
>{f.extLabel}</span
|
||||||
{#if f.description}
|
>
|
||||||
<span class="text-sm text-zinc-600">{f.description}</span>
|
<span class="flex-1 min-w-0 flex flex-col">
|
||||||
{/if}
|
<span class="flex items-baseline gap-1.5 min-w-0">
|
||||||
<span class="text-xs text-zinc-500">
|
<span class="text-sm text-stein-900 truncate">{f.label}</span>
|
||||||
{#if f.ext}<span class="uppercase">{f.ext}</span>{/if}
|
{#if f.sizeLabel}
|
||||||
{#if f.ext && f.sizeLabel} · {/if}
|
<span class="shrink-0 text-xs text-stein-400 tabular-nums">({f.sizeLabel})</span>
|
||||||
{#if f.sizeLabel}{f.sizeLabel}{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{#if f.description}
|
||||||
|
<span class="text-xs text-stein-400">{f.description}</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon="lucide:download"
|
||||||
|
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||||
|
import HeadlineBlock from './HeadlineBlock.svelte';
|
||||||
|
|
||||||
|
// CMS-Block in Isolation. `block` = die aufgelöste Block-Datenstruktur, wie sie
|
||||||
|
// der BlockRenderer von der API bekommt (siehe HeadlineBlockData in block-types.ts).
|
||||||
|
const meta = {
|
||||||
|
title: 'CMS-Blocks/Headline',
|
||||||
|
component: HeadlineBlock,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
block: { control: 'object' },
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof HeadlineBlock>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const H2: Story = {
|
||||||
|
args: {
|
||||||
|
block: { tag: 'h2', text: 'Windwiderstand im Steigerwald', align: 'left' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Zentriert: Story = {
|
||||||
|
args: {
|
||||||
|
block: { tag: 'h1', text: 'Wald statt Windkraft', align: 'center' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MitUmbruch: Story = {
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
tag: 'h2',
|
||||||
|
text: 'Erste Zeile\nZweite Zeile',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sobald kicker/action gesetzt → Headline rendert als SectionHeader.
|
||||||
|
export const AlsSektionskopf: Story = {
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
tag: 'h2',
|
||||||
|
text: 'Wir sind keine Lobby,\nkeine Partei, keine NGO.',
|
||||||
|
kicker: 'Über uns',
|
||||||
|
action: { url: '/about', linkName: 'Mehr über uns' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user