Update environment configuration, enhance component functionality, and improve project structure. Added Umami analytics configuration to .env.example and updated astro.config.mjs to use the new site URL. Introduced pagination component in BlogOverview.svelte, refactored image handling in ImageGalleryBlock.svelte, and improved SearchableTextBlock.svelte with tooltip support. Updated package.json with new scripts for Firebase deployment and added new dependencies for Storybook and Firebase. Enhanced styling and layout across various components for better user experience.

This commit is contained in:
Peter Meier
2026-02-27 14:13:38 +01:00
parent bb6ec20d45
commit 1f5454d04e
68 changed files with 3668 additions and 265 deletions
+13
View File
@@ -1,3 +1,16 @@
# URL des RustyCMS-Backends (ohne trailing slash)
# Dev: RustyCMS mit cargo run starten, dann hier eintragen
PUBLIC_CMS_URL=http://localhost:3000
# Produktion: Site-URL für Canonical, Sitemap, Open Graph (ohne trailing slash)
# Wird in astro.config als site genutzt; lokale Dev nutzt localhost automatisch
# PUBLIC_SITE_URL=https://windwiderstand.de
# Site-Name für og:site_name und JSON-LD (optional)
# PUBLIC_SITE_NAME=Bürgerinitiative Vachdorf
# Umami Analytics (nur wenn gesetzt wird das Script eingebunden)
# PUBLIC_UMAMI_SCRIPT_URL=https://eu.umami.is/script.js
# PUBLIC_UMAMI_WEBSITE_ID=your-website-id
# Firebase: Projekt in .firebaserc anpassen oder: firebase use <project-id>
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "windwiderstand"
}
}
+1
View File
@@ -23,6 +23,7 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
.firebase/
.history/
public/images/transformed/
public/pagefind
+44
View File
@@ -0,0 +1,44 @@
import type { StorybookConfig } from '@storybook/svelte-vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/lib/ui/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
framework: {
name: '@storybook/svelte-vite',
options: {},
},
docs: {
/** Name der automatisch erzeugten Docs-Seite pro Komponente */
defaultName: 'Dokumentation',
},
viteFinal: async (config) => {
config.plugins = config.plugins || [];
// Svelte-Plugin explizit vorn einfügen, damit .svelte-Dateien (auch aus node_modules/@storybook/svelte)
// vor dem Vite-Import-Analysis-Plugin transformiert werden nötig für Astro-Projekte ohne eigene vite.config
const sveltePlugins = svelte({
compilerOptions: {
runes: true,
},
include: ['**/*.svelte', '**/node_modules/@storybook/svelte/**/*.svelte'],
});
config.plugins.unshift(...sveltePlugins);
config.plugins.push(tailwindcss());
config.optimizeDeps = config.optimizeDeps ?? {};
config.optimizeDeps.exclude = [...(config.optimizeDeps.exclude ?? []), '@storybook/svelte'];
config.ssr = config.ssr ?? {};
const existing = config.ssr.noExternal;
const noExternalList = Array.isArray(existing)
? existing
: existing
? [existing]
: [];
const normalized = noExternalList.filter(
(x): x is string | RegExp => typeof x === 'string' || x instanceof RegExp
);
config.ssr.noExternal = [...normalized, '@storybook/svelte'];
return config;
},
};
export default config;
+27
View File
@@ -0,0 +1,27 @@
import type { Preview } from '@storybook/svelte';
import '../src/styles/global.css';
const preview: Preview = {
/** Autodocs für alle Stories aktivieren (Auto-Dokumentation aus Component + Args) */
tags: ['autodocs'],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: 'centered',
docs: {
/** Inhaltsverzeichnis auf der Docs-Seite */
toc: true,
/** Story in der Docs-Seite anzeigen (vermeidet leere Docs) */
story: {
inline: true,
iframeHeight: 400,
},
},
},
};
export default preview;
+1 -1
View File
@@ -7,7 +7,7 @@ import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config
// site wird für Sitemap und canonical/og-URLs genutzt (PUBLIC_SITE_URL oder Fallback)
const site = process.env.PUBLIC_SITE_URL || 'https://example.com';
const site = process.env.PUBLIC_SITE_URL || 'https://windwiderstand.de';
export default defineConfig({
site,
+40
View File
@@ -0,0 +1,40 @@
[12:32:38.362] [INFO] storybook v10.2.10
[12:32:39.017] [DEBUG] Getting package.json info for /Users/petermeier/@dev2026/rustyastro/package.json...
[12:32:39.019] [WARN] You are currently using Storybook 10.2.10 but you have packages which are incompatible with it:
- @storybook/addon-essentials@8.6.14 which depends on 8.6.14
Repo: https://github.com/storybookjs/storybook/tree/next/code/addons/essentials
- @storybook/addon-interactions@8.6.14 which depends on 8.6.14
Repo: https://github.com/storybookjs/storybook/tree/next/code/addons/interactions
- @storybook/blocks@8.6.14 which depends on ^8.6.14
Repo: https://github.com/storybookjs/storybook/tree/next/code/lib/blocks
- @storybook/test@8.6.15 which depends on 8.6.15
Repo: https://github.com/storybookjs/storybook/tree/next/code/lib/test
Please consider updating your packages or contacting the maintainers for compatibility details.
For more details on compatibility guidance, see:
https://github.com/storybookjs/storybook/issues/32836
[12:32:39.384] [INFO] Starting...
[12:32:39.488] [ERROR] SB_CORE-SERVER_0004 (NoMatchingExportError): There was an exports mismatch error when trying to build Storybook.
Please check whether the versions of your Storybook packages match whenever possible, as this might be the cause.
Problematic example:
{ "@storybook/react": "7.5.3", "@storybook/react-vite": "7.4.5", "storybook": "7.3.0" }
Correct example:
{ "@storybook/react": "7.5.3", "@storybook/react-vite": "7.5.3", "storybook": "7.5.3" }
Please run `npx storybook doctor` for guidance on how to fix this issue.
More info:

at buildOrThrow (file://./node_modules/storybook/dist/core-server/index.js:4509:9)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
at async buildDevStandalone (file://./node_modules/storybook/dist/core-server/index.js:7611:66)
at async withTelemetry (file://./node_modules/storybook/dist/_node-chunks/chunk-S3MWHNYJ.js:218:12)
at async dev (file://./node_modules/storybook/dist/bin/core.js:2734:3)
at async _Command.<anonymous> (file://./node_modules/storybook/dist/bin/core.js:2803:92)
[12:32:39.490] [WARN] Broken build, fix the error above.
You may need to refresh the browser.
[12:32:39.493] [INFO] Storybook collects completely anonymous usage telemetry. We use it to shape Storybook's roadmap and prioritize features. You can learn more, including how to opt out, at https://storybook.js.org/telemetry
+44
View File
@@ -0,0 +1,44 @@
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"headers": [
{
"source": "**",
"headers": [
{
"key": "Permissions-Policy",
"value": "browsing-topics=(), interest-cohort=(), geolocation=(), microphone=(), camera=()"
}
]
},
{
"source": "**/*.@(js|css|json|xml|rss|atom|svg|webp|jpg|jpeg|png|gif|ico|woff|woff2|ttf|eot)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "**/*.html",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=3600, must-revalidate"
}
]
},
{
"source": "/pagefind/**",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}
}
+10 -1
View File
@@ -6,15 +6,20 @@
"dev": "yarn generate:api-types; astro dev",
"build": "yarn generate:api-types && astro build && pagefind --site dist && cp -r dist/pagefind public/",
"preview": "astro preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"astro": "astro",
"generate:api-types": "node scripts/generate-api-types.mjs",
"postinstall": "yarn generate:api-types || true"
"postinstall": "yarn generate:api-types || true",
"firebase-deploy": "yarn build && firebase deploy --only hosting",
"firebase-deploy:nocache": "yarn build && firebase deploy --only hosting"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.0",
"@astrojs/svelte": "^7.2.5",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.17.1",
@@ -24,10 +29,14 @@
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@storybook/svelte": "10",
"@storybook/svelte-vite": "10",
"@types/node": "^22.10.0",
"astro-icon": "^1.1.5",
"firebase": "^11.0.1",
"openapi-typescript": "^7.4.0",
"pagefind": "^1.4.0",
"storybook": "10",
"typescript": "^5.9.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
+13 -76
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import PostCard from "./PostCard.svelte";
import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte";
import type { PostEntry } from "../lib/cms";
import { postHref } from "../lib/blog-utils";
@@ -32,31 +33,7 @@
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
}
function pageHref(page: number): string {
if (page <= 1) return tagHref(activeTag);
const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}`
: "";
return `${basePath}${tagPart}/page/${page}`;
}
const showPagination = $derived(totalPages > 1);
const hasPrev = $derived(currentPage > 1);
const hasNext = $derived(currentPage < totalPages);
const prevPage = $derived(currentPage - 1);
const nextPage = $derived(currentPage + 1);
const totalCount = $derived(totalPosts);
const pageNumbers = $derived.by(() => {
const max = 5;
let start = Math.max(1, currentPage - Math.floor(max / 2));
const end = Math.min(totalPages, start + max - 1);
if (end - start + 1 < max) start = Math.max(1, end - max + 1);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
});
const paginationLinkClass =
"h-6 w-6 mx-[2px] flex justify-center items-center text-slate-950 no-underline text-xs font-medium border border-gray-600 bg-white shadow-md";
</script>
<div class="blog-overview">
@@ -82,33 +59,12 @@
</div>
{/if}
{#if showPagination}
<div>
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
</a>
{/if}
{#each pageNumbers as num}
<a
href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50' : ''}"
aria-current={num === currentPage ? 'page' : undefined}
>
{num}
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
</a>
{/if}
</div>
</div>
</div>
{/if}
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="py-2 grid grid-cols-1 gap-4">
{#each posts as post}
@@ -121,31 +77,12 @@
</div>
<div class="flex flex-wrap items-center gap-2">
{#if showPagination}
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
</a>
{/if}
{#each pageNumbers as num}
<a
href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50' : ''}"
aria-current={num === currentPage ? 'page' : undefined}
>
{num}
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
</a>
{/if}
</div>
</div>
{/if}
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="grow"></div>
<div class="bg-white rounded-md font-thin">
<div class="inline-flex text-xs p-2">
+12 -5
View File
@@ -10,7 +10,7 @@
import LinkListBlock from "./blocks/LinkListBlock.svelte";
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import { isBlockLayoutBreakout } from "../lib/block-layout";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "../lib/block-layout";
import type { BlockLayout } from "../lib/block-layout";
import type {
RowContentLayout,
@@ -28,7 +28,11 @@
SearchableTextBlockData,
} from "../lib/block-types";
let { layout, class: className = "" }: { layout: RowContentLayout; class?: string } = $props();
let {
layout,
class: className = "",
searchableTextHelpTooltip,
}: { layout: RowContentLayout; class?: string; searchableTextHelpTooltip?: string } = $props();
function isResolvedBlock(item: unknown): item is ResolvedBlock {
return typeof item === "object" && item !== null;
@@ -110,7 +114,10 @@
{:else if blockType(item) === "post_overview"}
<PostOverviewBlock block={item as PostOverviewBlockData} />
{:else if blockType(item) === "searchable_text"}
<SearchableTextBlock block={item as SearchableTextBlockData} />
<SearchableTextBlock
block={item as SearchableTextBlockData}
helpTooltip={searchableTextHelpTooltip}
/>
{: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>
@@ -119,12 +126,12 @@
{/snippet}
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
<div class="col-span-12">
<div class="w-screen relative left-1/2 -translate-x-1/2 max-w-none bg-linear-to-b from-green-50 to-green-100 border-y border-green-100">
<div class="component-breakout--{blockType(item)} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none [background:var(--color-container-breakout)]">
<div class="container-custom py-4">
{@render blockContent()}
</div>
</div>
</div>
</div>
{:else}
{@render blockContent()}
{/if}
@@ -0,0 +1,121 @@
---
import BlockRenderer from "./BlockRenderer.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import type { RowContentLayout } from "../lib/block-types";
interface Props {
layout: RowContentLayout;
class?: string;
searchableTextHelpTooltip?: string;
}
const { layout, class: className = "", searchableTextHelpTooltip } = Astro.props;
function justifyToClass(j?: string): string {
switch (j) {
case "end":
return "justify-end";
case "center":
return "justify-center";
case "between":
return "justify-between";
case "around":
return "justify-around";
case "evenly":
return "justify-evenly";
default:
return "justify-start";
}
}
function alignToClass(a?: string): string {
switch (a) {
case "end":
return "items-end";
case "center":
return "items-center";
case "baseline":
return "items-baseline";
case "stretch":
return "items-stretch";
default:
return "items-start";
}
}
const rows = [
{
content: layout.row1Content ?? [],
justify: justifyToClass(layout.row1JustifyContent),
align: alignToClass(layout.row1AlignItems),
},
{
content: layout.row2Content ?? [],
justify: justifyToClass(layout.row2JustifyContent),
align: alignToClass(layout.row2AlignItems),
},
{
content: layout.row3Content ?? [],
justify: justifyToClass(layout.row3JustifyContent),
align: alignToClass(layout.row3AlignItems),
},
];
function isResolvedBlock(item: unknown): item is Record<string, unknown> {
return typeof item === "object" && item !== null;
}
function blockType(item: Record<string, unknown>): string {
return (item._type as string) ?? "unknown";
}
function layoutBreakout(item: unknown): boolean {
if (typeof item !== "object" || item === null) return false;
const layout = (item as Record<string, unknown>).layout as
| { breakout?: boolean }
| undefined;
return layout?.breakout === true;
}
---
<div class={`content my-6 ${className}`}>
{
rows.map((row) =>
Array.isArray(row.content) && row.content.length > 0 ? (
<div
class={`content-row grid grid-cols-12 gap-4 ${row.justify} ${row.align}`}
role="region"
data-cf-type="grid-row"
>
{row.content.map((item) => {
const content = isResolvedBlock(item) ? (
blockType(item) === "searchable_text" ? (
<SearchableTextBlock
client:load
block={item}
helpTooltip={searchableTextHelpTooltip}
/>
) : blockType(item) === "iframe" ||
blockType(item) === "image_gallery" ? (
<BlockRenderer client:load block={item} />
) : (
<BlockRenderer block={item} />
)
) : null;
const wrapped =
content && layoutBreakout(item) ? (
<div class="col-span-12">
<div class="w-screen relative left-1/2 -translate-x-1/2 max-w-none">
{content}
</div>
</div>
) : (
content
);
return wrapped;
})}
</div>
) : null,
)
}
</div>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
currentPage = 1,
totalPages = 1,
basePath = "/posts",
activeTag = null,
}: {
currentPage: number;
totalPages: number;
basePath?: string;
activeTag?: string | null;
} = $props();
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return basePath;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
}
function pageHref(page: number): string {
if (page <= 1) return tagHref(activeTag ?? null);
const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}`
: "";
return `${basePath}${tagPart}/page/${page}`;
}
const showPagination = $derived(totalPages > 1);
const hasPrev = $derived(currentPage > 1);
const hasNext = $derived(currentPage < totalPages);
const prevPage = $derived(currentPage - 1);
const nextPage = $derived(currentPage + 1);
const pageNumbers = $derived.by(() => {
const max = 5;
let start = Math.max(1, currentPage - Math.floor(max / 2));
const end = Math.min(totalPages, start + max - 1);
if (end - start + 1 < max) start = Math.max(1, end - max + 1);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
});
const paginationLinkClass =
" bg-linear-to-b from-stein-50 to-stein-100 rounded-full h-6 w-6 mx-[2px] flex justify-center items-center text-stein-950 no-underline text-xs font-medium border border-stein-200 shadow-md";
</script>
{#if showPagination}
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
</a>
{/if}
{#each pageNumbers as num}
<a
href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
aria-current={num === currentPage ? 'page' : undefined}
>
{num}
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
</a>
{/if}
</div>
</div>
{/if}
+1 -1
View File
@@ -22,7 +22,7 @@
if (variant === "green") return "bg-gradient-to-b from-green-300 to-green-400 text-black! ";
if (variant === "blue") return "bg-gradient-to-b from-teal-300 to-teal-400 text-black! ";
if (variant === "inactive") return "bg-gradient-to-t from-gray-200 to-gray-300 text-gray-500! shadow-none ring-black/15!";
if (variant === "date") return "bg-green-700 text-white! ring-white!";
if (variant === "date") return "bg-gradient-to-b from-green-50 to-green-100 text-green-700! ring-green-200!";
return "bg-gray-200 text-gray-500";
});
const shadowClass = $derived.by(() => {
+78 -11
View File
@@ -21,7 +21,7 @@
);
const withUrl = $derived(
images
.map((img) => ({ img, url: imageUrl(img) }))
.map((img) => ({ img, url: img.resolvedSrc ?? imageUrl(img) }))
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
);
@@ -40,6 +40,61 @@
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
}
const SWIPE_THRESHOLD = 50;
let startX = $state(0);
let isDragging = $state(false);
let dragDelta = $state(0);
function onPointerDown(e: PointerEvent) {
if (withUrl.length <= 1) return;
startX = e.clientX;
isDragging = true;
dragDelta = 0;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
dragDelta = e.clientX - startX;
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
}
function onPointerUp() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
function onTouchStart(e: TouchEvent) {
if (withUrl.length <= 1) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
function onTouchMove(e: TouchEvent) {
if (!isDragging || !e.touches[0]) return;
const delta = e.touches[0].clientX - startX;
dragDelta = delta;
if (Math.abs(delta) > 10) e.preventDefault();
}
function onTouchEnd() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
</script>
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
@@ -50,22 +105,34 @@
{/if}
{#if withUrl.length === 0}
<p class="text-sm text-zinc-500">Keine Bilder in der Galerie.</p>
<p class="text-sm text-stein-500">Keine Bilder in der Galerie.</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<img
src={withUrl[0].url}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-4/3"
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
/>
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-zinc-600">{withUrl[0].img.description}</figcaption>
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
{/if}
</figure>
{:else}
<div class="relative">
<div class="relative overflow-hidden rounded-sm bg-zinc-100 aspect-4/3">
<div class="group relative">
<div
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
role="region"
aria-label="Galerie: wischen zum Wechseln"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchmove={onTouchMove}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
>
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
@@ -80,7 +147,7 @@
/>
{#if current.img.description}
<figcaption
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-sm text-white bg-black/50 rounded-b-sm"
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
>
{current.img.description}
</figcaption>
@@ -91,7 +158,7 @@
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label="Vorheriges Bild"
onclick={prev}
>
@@ -99,7 +166,7 @@
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label="Nächstes Bild"
onclick={next}
>
@@ -114,8 +181,8 @@
aria-label="Bild {i + 1}"
aria-selected={i === currentIndex}
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
? "bg-green-600"
: "bg-zinc-300 hover:bg-zinc-400"}"
? "bg-wald-600"
: "bg-stein-300 hover:bg-stein-400"}"
onclick={() => (currentIndex = i)}
></button>
{/each}
@@ -9,8 +9,22 @@
import Icon from "@iconify/svelte";
import Tag from "../Tag.svelte";
import Tags from "../Tags.svelte";
import Tooltip from "../../lib/ui/Tooltip.svelte";
let { block, class: className = "" }: { block: SearchableTextBlockData; class?: string } = $props();
const DEFAULT_HELP_TOOLTIP =
"Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren“ könnt ihr den Text in die Zwischenablage übernehmen (z.B. für Stellungnahmen).";
let {
block,
class: className = "",
helpTooltip,
}: {
block: SearchableTextBlockData;
class?: string;
helpTooltip?: string;
} = $props();
const helpTooltipText = $derived(helpTooltip ?? DEFAULT_HELP_TOOLTIP);
marked.setOptions({ gfm: true });
@@ -42,6 +56,33 @@
let searchQuery = $state("");
let selectedTag = $state("all");
let copiedIndex = $state<number | null>(null);
async function copyFragmentContent(fragment: { title: string; text: string }, index: number) {
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(plain);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
// Fallback für ältere Browser
try {
const ta = document.createElement("textarea");
ta.value = plain;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
/* ignore */
}
}
}
/** Wird im Template aufgerufen, damit Reaktivität auf searchQuery/selectedTag sicher greift. */
function getVisibleFragments() {
@@ -62,7 +103,7 @@
const visibleFragments = $derived(getVisibleFragments());
const HIGHLIGHT_CLASS = "bg-amber-200 rounded px-0.5";
const HIGHLIGHT_CLASS = "bg-wald-200 text-wald-900 rounded px-0.5";
/** Suchbegriff in Plain-Text hervorheben (HTML-escaped, Treffer in <mark>). */
function highlightInText(text: string, term: string): string {
@@ -106,35 +147,35 @@
</script>
<div
class="searchable-text flex flex-col gap-0 border border-gray-200 bg-slate-50 {layoutClasses} {className}"
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
data-block-type="searchable_text"
data-block-slug={block._slug}
>
<div class="p-4 md:p-6">
{#if block.title}
<h2 class="text-xl md:text-2xl font-bold bg-slate-50 mb-2">{block.title}</h2>
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">{block.title}</h2>
{/if}
{#if descriptionHtml}
<div class="markdown text-zinc-600 max-w-none prose prose-zinc prose-sm bg-slate-50">{@html descriptionHtml}</div>
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">{@html descriptionHtml}</div>
{/if}
</div>
<div class="px-4 md:px-6">
<div class="inline-flex bg-amber-100 border border-amber-200/60 text-xs p-3 text-amber-800 items-start gap-2 mb-4 rounded">
<span class="shrink-0" aria-hidden="true"></span>
<span>
Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen.
</span>
</div>
</div>
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-slate-300 bg-white/90 backdrop-blur-sm md:static md:bg-slate-200 md:backdrop-blur-none shadow-md md:shadow-none">
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none">
<div class="flex flex-col gap-2">
<div class="relative flex items-center">
<div class="relative flex items-center gap-2">
<Tooltip content={helpTooltipText} placement="top">
<button
type="button"
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
aria-label="Hilfe zur Suche anzeigen"
>
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
</button>
</Tooltip>
<input
type="search"
placeholder="In Titeln und Inhalten suchen…"
class="w-full text-sm px-4 py-2 pr-10 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-transparent bg-white"
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label="Suche in Titeln und Inhalten"
@@ -142,14 +183,14 @@
{#if searchQuery}
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-gray-200 text-gray-500"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Suche leeren"
>
</button>
{/if}
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none size-5" aria-hidden="true" />
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5" aria-hidden="true" />
</div>
{#if allTags.length > 0}
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
@@ -174,7 +215,7 @@
</div>
</header>
<div class="text-xs text-slate-600 px-4 md:px-6 py-2 bg-slate-200 border-b border-slate-300" aria-live="polite">
<div class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200" aria-live="polite">
{#if visibleFragments.length === 0}
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
{:else}
@@ -186,19 +227,37 @@
{/if}
</div>
<div class="flex flex-col gap-0 bg-slate-200">
{#each visibleFragments as fragment}
<details class="group border-b border-slate-300 last:border-b-0">
<summary class="cursor-pointer list-none px-4 md:px-6 py-3 bg-slate-100 hover:bg-slate-50 text-sm font-medium flex items-start gap-2">
<div class="grow">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
<div class="flex flex-col gap-0 bg-stein-100">
{#each visibleFragments as fragment, i}
<details class="group border-b border-stein-200 last:border-b-0">
<summary class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors">
<div class="flex items-start gap-2 min-w-0">
<div class="grow min-w-0">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
<Icon icon="mdi:chevron-down" class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform" aria-hidden="true" />
</div>
{#if fragment.tags.length > 0}
<span class="flex flex-wrap gap-1 justify-end">
<span class="flex flex-wrap gap-1">
<Tags tags={fragment.tags} variant="white" />
</span>
{/if}
<Icon icon="mdi:chevron-down" class="shrink-0 text-slate-400 group-open:rotate-180 transition-transform" aria-hidden="true" />
</summary>
<div class="px-4 md:px-6 py-4 bg-white markdown prose prose-zinc prose-sm max-w-none">
<div class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs">
<div class="flex justify-end mb-3 -mt-1">
<button
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
onclick={(e) => { e.preventDefault(); copyFragmentContent(fragment, i); }}
aria-label="Inhalt in Zwischenablage kopieren"
>
{#if copiedIndex === i}
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
<span>Kopiert!</span>
{:else}
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
<span>Inhalt kopieren</span>
{/if}
</button>
</div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
@@ -14,7 +14,7 @@
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
{#if block.youtubeId}
<div class="aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
<iframe
title={block.title ?? "YouTube-Video"}
src={embedUrl}
@@ -25,9 +25,9 @@
></iframe>
</div>
{#if block.description}
<p class="mt-1 text-sm text-zinc-600">{block.description}</p>
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
{/if}
{:else}
<p class="text-sm text-zinc-500">YouTube-Video-ID fehlt.</p>
<div class="text-sm text-stein-500">YouTube-Video-ID fehlt.</div>
{/if}
</div>
+2
View File
@@ -2,6 +2,8 @@
interface ImportMetaEnv {
readonly PUBLIC_CMS_URL: string;
readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
}
interface ImportMeta {
+54 -4
View File
@@ -5,6 +5,7 @@ import Header from "../components/Header.svelte";
import HeaderOverlay from "../components/HeaderOverlay.svelte";
import Footer from "../components/Footer.svelte";
import TopBanner from "../components/TopBanner.svelte";
import Breadcrumbs from "../lib/ui/Breadcrumbs.svelte";
import {
getNavigationByKey,
NavigationKeys,
@@ -19,6 +20,7 @@ import { resolveImageUrls, ensureTransformedImage } from "../lib/rusty-image";
import {
DEFAULT_SOCIAL_IMAGE_URL,
ROW_RESOLVE,
SITE_NAME,
SOCIAL_IMAGE_TRANSFORM,
} from "../lib/constants";
import { marked } from "marked";
@@ -45,6 +47,8 @@ interface Props {
showBannerInLayout?: boolean;
/** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */
image?: string | null;
/** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. */
breadcrumbItems?: { href?: string; label: string }[];
}
const {
@@ -55,6 +59,7 @@ const {
topFullwidthBanner,
showBannerInLayout = false,
image: imageProp = null,
breadcrumbItems = undefined,
} = Astro.props;
// Header: Navigation laden und Links bauen
@@ -240,7 +245,9 @@ const siteFromEnv = (import.meta.env.SITE || "").replace(/\/$/, "");
const isLocalhost =
Astro.url.origin.startsWith("http://localhost") ||
Astro.url.origin.startsWith("http://127.0.0.1");
const baseUrl = isLocalhost ? Astro.url.origin : (siteFromEnv || Astro.url.origin);
const baseUrl = isLocalhost
? Astro.url.origin
: siteFromEnv || Astro.url.origin;
const pathname = Astro.url.pathname || "/";
const canonical = `${baseUrl}${pathname}`;
@@ -269,6 +276,8 @@ if (needsTransform) {
socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`;
}
const socialImage = socialImageResolved ?? "";
const siteName =
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner)
let topBanner: FullwidthBannerEntry | null = null;
@@ -357,6 +366,22 @@ const titleSubheadlineHtml =
)
}
{socialImage && <meta property="og:image" content={socialImage} />}
{
socialImage && (
<>
<meta
property="og:image:width"
content={String(SOCIAL_IMAGE_TRANSFORM.width)}
/>
<meta
property="og:image:height"
content={String(SOCIAL_IMAGE_TRANSFORM.height)}
/>
</>
)
}
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={siteName} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} />
@@ -367,7 +392,25 @@ const titleSubheadlineHtml =
)
}
{socialImage && <meta name="twitter:image" content={socialImage} />}
<!-- JSON-LD WebSite -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
"name": siteName,
"url": baseUrl,
"inLanguage": "de-DE",
})}></script>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
{
import.meta.env.PUBLIC_UMAMI_SCRIPT_URL &&
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
<script
defer
src={import.meta.env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
/>
)
}
</head>
<body class="flex min-h-screen flex-col text-zinc-900">
<Header
@@ -390,11 +433,18 @@ const titleSubheadlineHtml =
}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{breadcrumbItems && breadcrumbItems.length > 0 && (
<Breadcrumbs items={breadcrumbItems} client:load />
)}
{
showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
<div class="mb-6 pt-10 pageTitle">
{titleHeadlineHtml && <h1 set:html={titleHeadlineHtml} />}
{titleSubheadlineHtml && <h2 set:html={titleSubheadlineHtml} />}
{titleHeadlineHtml && (
<h1 class="pageTitle" set:html={titleHeadlineHtml} />
)}
{titleSubheadlineHtml && (
<h2 class="pageTitle" set:html={titleSubheadlineHtml} />
)}
</div>
)
}
@@ -402,6 +452,6 @@ const titleSubheadlineHtml =
</div>
</main>
<Footer footer={footerData} />
<script src="/pagefind/pagefind-ui.js" is:inline />
<script src="/pagefind/pagefind-ui.js" is:inline></script>
</body>
</html>
+13 -1
View File
@@ -127,7 +127,7 @@ export function getBlockLayoutClasses(
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
if (breakout) {
return `w-full ${spaceClass}`;
return "w-full";
}
const mobile = String(layout.mobile ?? "12");
@@ -143,6 +143,18 @@ export function getBlockLayoutClasses(
return parts.filter(Boolean).join(" ");
}
/** Tailwind-Klasse für spaceBottom (z. B. am Breakout-Wrapper). */
export function getSpaceBottomClass(
layout: BlockLayout | undefined | null,
): string {
if (!layout || typeof layout !== "object") return "mb-4";
const sb =
typeof layout.spaceBottom === "number"
? layout.spaceBottom
: Number(layout.spaceBottom);
return SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
}
/** Prüft, ob das Layout breakout (fullwidth) haben soll. */
export function isBlockLayoutBreakout(
layout: BlockLayout | undefined | null,
+2
View File
@@ -87,6 +87,8 @@ export interface ImageGalleryImage {
src?: string;
description?: string;
file?: { url?: string };
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (16:9). */
resolvedSrc?: string;
}
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
+1 -1
View File
@@ -230,7 +230,7 @@ export async function resolvePostOverviewBlocks(
}
for (const post of item.postsResolved as PostEntry[]) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
if (raw && import.meta.env.SSR) {
try {
const { ensureTransformedImage } = await import("./rusty-image");
const url = await ensureTransformedImage(raw, {
+3 -3
View File
@@ -2543,7 +2543,7 @@ export interface components {
* @enum {string}
*/
alignment: "left" | "center" | "right";
/** @description Markdown/body text content */
/** @description Markdown/body text: either inline or file reference (file:path, e.g. file:slug.content.md) */
content?: string;
/** @description Column width (grid) like CF_ComponentLayout */
layout?: {
@@ -2587,7 +2587,7 @@ export interface components {
* @enum {string}
*/
alignment: "left" | "center" | "right";
/** @description Markdown/body text content */
/** @description Markdown/body text: either inline or file reference (file:path, e.g. file:slug.content.md) */
content?: string;
/** @description Column width (grid) like CF_ComponentLayout */
layout?: {
@@ -10469,7 +10469,7 @@ export interface operations {
fit?: "fill" | "contain" | "cover";
/** @description Output format */
format?: "jpeg" | "png" | "webp" | "avif";
/** @description JPEG quality (1100) */
/** @description Quality 1100 for JPEG and WebP (lossy) */
quality?: number;
};
header?: never;
+52
View File
@@ -524,3 +524,55 @@ export async function getFullwidthBannerBySlug(
}
return (await res.json()) as FullwidthBannerEntry;
}
/** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */
export type TranslationEntry = { _slug?: string; text: string };
/**
* Translation anhand Slug (GET /api/content/translation/:slug).
* Für zukünftiges Translation-Management: Texte aus dem CMS statt hardcoded.
*/
export async function getTranslationBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationEntry | null> {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
return null;
}
return (await res.json()) as TranslationEntry;
}
/** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */
export type TranslationBundleEntry = {
_slug?: string;
strings: Record<string, string>;
};
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
* Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
* Slug z. B. "de" für content/de/translation_bundle/de.json5.
*/
export async function getTranslationBundleBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationBundleEntry | null> {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
return null;
}
return (await res.json()) as TranslationBundleEntry;
}
+3
View File
@@ -30,3 +30,6 @@ export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
export const SITE_NAME = "RustyAstro";
+55 -16
View File
@@ -96,8 +96,11 @@ export async function ensureTransformedImage(
throw new Error("rusty-image: project root (process.cwd) not available");
}
const publicDir = path.join(projectRoot, "public");
const dir = path.join(publicDir, SUBDIR);
// Build: in dist schreiben, damit Preview/Deploy die Bilder ausliefert (public wird vor dem Rendern kopiert).
// Dev: in public schreiben, damit der Dev-Server sie ausliefert.
const isBuild = typeof import.meta !== "undefined" && (import.meta as { env?: { PROD?: boolean } }).env?.PROD === true;
const baseDir = isBuild ? path.join(projectRoot, "dist") : path.join(projectRoot, "public");
const dir = path.join(baseDir, SUBDIR);
const filename = `${hash}${dimSuffix}.${ext}`;
const filePath = path.join(dir, filename);
const publicPath = `/${SUBDIR}/${filename}`;
@@ -121,6 +124,7 @@ export async function ensureTransformedImage(
searchParams.set("quality", String(params.quality));
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
console.log("[rusty-image] GET", transformUrl);
const res = await fetch(transformUrl);
if (!res.ok) {
throw new Error(
@@ -183,6 +187,15 @@ function getImageBlockUrl(block: {
return null;
}
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageUrl(
img: { src?: string; file?: { url?: string } },
): string | null {
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
return null;
}
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
export interface RowContentLayoutLike {
row1Content?: unknown[];
@@ -204,6 +217,16 @@ export async function resolveContentImages(
layout.row3Content,
].filter((r): r is unknown[] => Array.isArray(r));
/** Galerie: 1280×720 (16:9), WebP, Qualität 80 kleinere Dateien. */
const galleryParams: RustyImageTransformParams = {
width: 1280,
height: 720,
fit: "cover",
format: "webp",
quality: 80,
...transformParams,
};
for (const content of rows) {
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
@@ -211,23 +234,39 @@ export async function resolveContentImages(
_type?: string;
image?: unknown;
img?: unknown;
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
resolvedImageSrc?: string;
};
if (block._type !== "image") continue;
const url = getImageBlockUrl(
block as {
image?: string | { file?: { url?: string }; title?: string };
img?: string | { file?: { url?: string }; title?: string };
},
);
if (!url) continue;
try {
block.resolvedImageSrc = await ensureTransformedImage(
url,
transformParams,
if (block._type === "image") {
const url = getImageBlockUrl(
block as {
image?: string | { file?: { url?: string }; title?: string };
img?: string | { file?: { url?: string }; title?: string };
},
);
} catch (e) {
console.warn("[rusty-image] resolve failed for", url, e);
if (!url) continue;
try {
block.resolvedImageSrc = await ensureTransformedImage(
url,
transformParams,
);
} catch (e) {
console.warn("[rusty-image] resolve failed for", url, e);
}
continue;
}
if (block._type === "image_gallery" && Array.isArray(block.images)) {
for (const img of block.images) {
if (!img || typeof img !== "object") continue;
const url = getGalleryImageUrl(img);
if (!url || !url.startsWith("http")) continue;
try {
(img as { resolvedSrc?: string }).resolvedSrc =
await ensureTransformedImage(url, galleryParams);
} catch (e) {
console.warn("[rusty-image] gallery resolve failed for", url, e);
}
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Badge from './Badge.svelte';
const meta = {
title: 'UI/Badge',
component: Badge,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Kleine Label-Badge: nur Text (span), als Link (href) oder als Button (onclick). Varianten: default, primary (Wald), accent (Himmel), inactive, date.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary', 'accent', 'inactive', 'date'],
},
},
} satisfies Meta<Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Badge',
variant: 'default',
},
};
export const Primary: Story = {
args: {
label: 'Primär',
variant: 'primary',
},
};
export const Accent: Story = {
args: {
label: 'Akzent',
variant: 'accent',
},
};
export const Inactive: Story = {
args: {
label: 'Inaktiv',
variant: 'inactive',
},
};
export const Date: Story = {
args: {
label: '12.03.2025',
variant: 'date',
},
};
export const AsLink: Story = {
args: {
label: 'Als Link',
variant: 'primary',
href: '#',
},
};
export const Active: Story = {
args: {
label: 'Aktiver Tab',
variant: 'primary',
active: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Variant = 'default' | 'primary' | 'accent' | 'inactive' | 'date';
let {
label = '',
variant = 'default',
href = null as string | null,
active = false,
onclick = null as (() => void) | null,
} = $props();
const baseClass =
'inline-flex shrink-0 whitespace-nowrap rounded-full py-1 px-2.5 text-[0.6875rem] font-medium transition-all ring-1 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1';
const variantClasses = $derived(
{
default: 'bg-stein-0 text-stein-800 ring-stein-200 hover:bg-stein-50',
primary: 'bg-wald-500 text-white ring-wald-600/30 hover:bg-wald-600',
accent: 'bg-himmel-400 text-white ring-himmel-500/30 hover:bg-himmel-500',
inactive: 'bg-stein-100 text-stein-500 ring-stein-200',
date: 'bg-wald-600 text-white ring-wald-700/40',
}[variant]
);
const shadowClass = $derived(
href || onclick
? variant === 'date'
? 'shadow-[0_0_6px_rgba(0,0,0,0.25)]'
: 'shadow-[0_2px_6px_rgba(0,0,0,0.12)]'
: ''
);
const classes = $derived(
`${baseClass} ${variantClasses} ${shadowClass} ${active ? 'pointer-events-none ring-2' : ''}`
);
</script>
{#if href}
<a
href={href}
class={classes}
aria-current={active ? 'true' : undefined}
>
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
aria-current={active ? 'true' : undefined}
onclick={onclick}
>
{label}
</button>
{:else}
<span class={classes}>{label}</span>
{/if}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Breadcrumbs from './Breadcrumbs.svelte';
const meta = {
title: 'UI/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Breadcrumb-Navigation. `items`: Array aus `{ href?, label }`. Letzter Eintrag ohne href = aktueller Seiten-Titel.',
},
},
},
} satisfies Meta<Breadcrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ href: '/', label: 'Startseite' },
{ href: '/aktuelles', label: 'Aktuelles' },
{ label: 'Artikel-Titel' },
],
},
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import "../iconify-offline";
import Icon from "@iconify/svelte";
/** @type {{ href?: string; label: string }[]} */
let { items = [] } = $props();
</script>
<nav aria-label="Breadcrumb" class="overflow-x-auto">
<ol class="list-none flex flex-nowrap items-center gap-x-1 text-xs py-4 pl-0 m-0!">
{#each items as item, i}
<li class="flex items-center gap-x-1.5 shrink-0 whitespace-nowrap">
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="inline-flex font-medium no-underline items-center gap-1.5 text-stein-500 hover:text-wald-600 transition-colors duration-150 rounded px-1 -mx-1 py-0.5 -my-0.5"
aria-label={i === 0 ? item.label : undefined}
>
{#if i === 0}
<Icon icon="mdi:home" class="size-4 shrink-0" aria-hidden="true" />
{:else}
<span>{item.label}</span>
{/if}
</a>
{:else}
<span
class="font-medium text-stein-800"
aria-current={i === items.length - 1 ? 'page' : undefined}
>
{item.label}
</span>
{/if}
{#if i < items.length - 1}
<span aria-hidden="true" class="shrink-0 text-stein-300">
<Icon icon="mdi:chevron-right" class="size-3.5" />
</span>
{/if}
</li>
{/each}
</ol>
</nav>
+81
View File
@@ -0,0 +1,81 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Button from './Button.svelte';
const meta = {
title: 'UI/Button',
component: Button,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Primärer Aktions-Button. Varianten: primary, secondary, ghost. Größen: sm, md, large.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'large'],
},
},
} satisfies Meta<Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
label: 'Mitmachen',
variant: 'primary',
size: 'md',
},
};
export const Secondary: Story = {
args: {
label: 'Mehr erfahren',
variant: 'secondary',
},
};
export const Ghost: Story = {
args: {
label: 'Abbrechen',
variant: 'ghost',
},
};
export const Small: Story = {
args: {
label: 'Klein',
variant: 'primary',
size: 'sm',
},
};
export const Large: Story = {
args: {
label: 'Großer Button',
variant: 'primary',
size: 'large',
},
};
export const Loading: Story = {
args: {
label: 'Laden',
loading: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
disabled: true,
},
};
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
} = $props();
const base =
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else}
<slot>{label}</slot>
{/if}
</button>
+43
View File
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Checkbox from './Checkbox.svelte';
const meta = {
title: 'UI/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Checkbox mit optionalem Label. `checked` und `disabled` steuerbar.',
},
},
},
argTypes: {
checked: { control: 'boolean' },
},
} satisfies Meta<Checkbox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unchecked: Story = {
args: {
label: 'Option auswählen',
checked: false,
},
};
export const Checked: Story = {
args: {
label: 'Option ausgewählt',
checked: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
checked: false,
disabled: true,
},
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
name = '',
value = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="checkbox"
bind:checked
{disabled}
{name}
{value}
class="h-5 w-5 rounded border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import ColorsShowcase from './ColorsShowcase.svelte';
const meta = {
title: 'Foundation/Colors',
component: ColorsShowcase,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Farbpaletten des Windwiderstand Design Systems: Wald (Primary), Erde (Secondary), Himmel (Accent), Stein (Neutrals) sowie semantische Farben.',
},
},
},
} satisfies Meta<ColorsShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Palettes: Story = {};
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
/** Farbwerte aus global.css @theme damit Paletten in Storybook garantiert sichtbar sind */
const paletteHex: Record<string, Record<string, string>> = {
wald: {
'50': '#f0f5f1', '100': '#d9e8dc', '200': '#b3d1b9', '300': '#7fb58a',
'400': '#4a9960', '500': '#2d7a45', '600': '#236335', '700': '#1a4d28',
'800': '#12361c', '900': '#0a2011',
},
erde: {
'50': '#faf6f1', '100': '#f0e6d6', '200': '#e0ccad', '300': '#c9a87a',
'400': '#b08a52', '500': '#8b6d3f', '600': '#6e5530', '700': '#524023',
'800': '#372b18', '900': '#1c160c',
},
himmel: {
'50': '#f2f5f7', '100': '#dce4ea', '200': '#b8c9d4', '300': '#8aaabb',
'400': '#5e8ba2', '500': '#436e85', '600': '#34576a', '700': '#264150',
'800': '#1a2c36', '900': '#0e181e',
},
stein: {
'0': '#ffffff', '50': '#f7f8f7', '100': '#eceeed', '200': '#d5d8d6',
'300': '#b0b5b2', '400': '#868c89', '500': '#636966', '600': '#4a4f4c',
'700': '#333735', '800': '#1f2221', '900': '#0f1110',
},
};
const semanticHex = [
{ name: 'Success', main: '#2d7a45', subtle: '#f0f5f1' },
{ name: 'Warning', main: '#a6780a', subtle: '#fdf8ec' },
{ name: 'Error', main: '#b53629', subtle: '#fdf2f1' },
{ name: 'Info', main: '#436e85', subtle: '#f2f5f7' },
] as const;
const palettes = [
{
name: 'Wald (Primary)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'wald',
},
{
name: 'Erde (Secondary)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'erde',
},
{
name: 'Himmel (Accent / Links)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'himmel',
},
{
name: 'Stein (Neutrals)',
tokens: ['0', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'stein',
},
] as const;
const appColors = [
{ name: 'Text Primary', var: '--text-primary' },
{ name: 'Text Secondary', var: '--text-secondary' },
{ name: 'Text Tertiary', var: '--text-tertiary' },
{ name: 'Bg Primary', var: '--bg-primary' },
{ name: 'Bg Secondary', var: '--bg-secondary' },
{ name: 'Bg Tertiary', var: '--bg-tertiary' },
{ name: 'Accent', var: '--accent' },
{ name: 'Accent Hover', var: '--accent-hover' },
{ name: 'Link', var: '--link' },
{ name: 'Border Default', var: '--border-default' },
{ name: 'Border Strong', var: '--border-strong' },
{ name: 'Btn BG', var: '--color-btn-bg' },
{ name: 'Btn Hover BG', var: '--color-btn-hover-bg' },
{ name: 'Btn Text', var: '--color-btn-txt' },
{ name: 'Navigation', var: '--color-navigation' },
{ name: 'Font Highlight (Error)', var: '--color-font-highlight' },
{ name: 'Container Breakout', var: '--color-container-breakout' },
] as const;
</script>
<div class="space-y-10 text-stein-800">
{#each palettes as palette}
<section>
<h3 class="mb-3 text-lg font-semibold">{palette.name}</h3>
<div class="flex flex-wrap gap-2">
{#each palette.tokens as t}
{@const hex = paletteHex[palette.prefix]?.[t]}
<div class="flex flex-col items-center gap-1">
<div
class="h-14 w-20 rounded-lg border border-stein-200 shadow-sm"
style="background-color: {hex ?? 'transparent'}"
title="--color-{palette.prefix}-{t}"
></div>
<span class="text-xs text-stein-500">{t}</span>
</div>
{/each}
</div>
</section>
{/each}
<section>
<h3 class="mb-3 text-lg font-semibold">Semantik (Success, Warning, Error, Info)</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{#each semanticHex as s}
<div class="flex flex-col gap-2 rounded-lg border border-stein-200 p-3">
<div
class="h-12 rounded border border-stein-200"
style="background-color: {s.main}"
title="{s.name} main"
></div>
<div
class="h-8 rounded border border-stein-200"
style="background-color: {s.subtle}"
title="{s.name} subtle"
></div>
<span class="text-sm font-medium">{s.name}</span>
<span class="text-xs text-stein-500">+ subtle</span>
</div>
{/each}
</div>
</section>
<section>
<h3 class="mb-3 text-lg font-semibold">Semantik & App ( :root )</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5">
{#each appColors as a}
<div class="flex flex-col gap-1 rounded-lg border border-stein-200 p-2">
<div
class="h-10 w-full rounded border border-stein-200"
style="background-color: var({a.var})"
title="{a.var}"
></div>
<span class="text-xs font-medium">{a.name}</span>
<span class="truncate text-[0.65rem] text-stein-500" title="{a.var}">{a.var}</span>
</div>
{/each}
</div>
</section>
</div>
+34
View File
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Header from './Header.svelte';
const meta = {
title: 'UI/Header',
component: Header,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Seiten-Header mit Navigation (links), CTA-Button und optionalem Mobilmenü.',
},
},
},
argTypes: {
mobileMenuOpen: { control: 'boolean' },
},
} satisfies Meta<Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
links: [
{ href: '/ueber-uns', label: 'Über uns' },
{ href: '/aktuelles', label: 'Aktuelles', active: true },
{ href: '/fakten', label: 'Fakten' },
{ href: '/initiativen', label: 'Initiativen' },
],
ctaLabel: 'Mitmachen',
ctaHref: '/mitmachen',
},
};
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
/** @type {{ href: string; label: string; active?: boolean }[]} */
let {
links = [],
ctaHref = '/mitmachen',
ctaLabel = 'Mitmachen',
logoSrc = '/logo.svg',
logoAlt = 'Windwiderstand',
mobileMenuOpen = $bindable(false),
} = $props();
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
</script>
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[51] focus:rounded focus:bg-wald-500 focus:px-2 focus:py-1 focus:text-white focus:outline-none">
Zum Inhalt springen
</a>
<header
class="sticky top-0 z-50 border-b border-stein-200 bg-stein-0/95 shadow-sm backdrop-blur-sm lg:shadow-none"
>
<nav
aria-label="Hauptnavigation"
class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 lg:h-[72px] lg:px-8"
>
<a href="/" class="flex items-center gap-2">
<img src={logoSrc} alt={logoAlt} class="h-8" />
</a>
<div class="hidden items-center gap-8 lg:flex">
{#each links as link}
{#if link.active}
<a
href={link.href}
class="border-b-2 border-wald-500 text-wald-500 text-[0.9375rem] font-medium"
aria-current="page"
>
{link.label}
</a>
{:else}
<a
href={link.href}
class="text-stein-700 text-[0.9375rem] font-medium hover:text-wald-600"
>
{link.label}
</a>
{/if}
{/each}
</div>
<a
href={ctaHref}
class="hidden rounded-lg bg-wald-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-wald-600 lg:inline-block"
>
{ctaLabel}
</a>
<button
type="button"
class="lg:hidden"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
onclick={toggleMobileMenu}
>
<span class="sr-only">Menu öffnen</span>
{#if mobileMenuOpen}
<span class="text-2xl" aria-hidden="true"></span>
{:else}
<span class="text-2xl" aria-hidden="true"></span>
{/if}
</button>
</nav>
{#if mobileMenuOpen}
<div
id="mobile-menu"
class="absolute left-0 right-0 top-16 z-50 flex flex-col gap-4 border-b border-stein-200 bg-stein-0 p-4 lg:hidden"
role="dialog"
aria-label="Mobile Menü"
>
{#each links as link}
<a
href={link.href}
class="text-stein-700 text-[0.9375rem] font-medium hover:text-wald-600 {link.active
? 'border-b-2 border-wald-500 text-wald-500'
: ''}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
{/each}
<a
href={ctaHref}
class="rounded-lg bg-wald-500 px-5 py-2.5 text-center text-sm font-semibold text-white hover:bg-wald-600"
>
{ctaLabel}
</a>
</div>
{/if}
</header>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Radio from './Radio.svelte';
const meta = {
title: 'UI/Radio',
component: Radio,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Radio-Button für Gruppen. `bind:group` vom Parent hält den value des gewählten Radios. Gleicher `name` für alle in einer Gruppe.',
},
},
},
argTypes: {
group: { control: 'text', description: 'Wert der ausgewählten Option (value des gewählten Radios)' },
},
} satisfies Meta<Radio>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unselected: Story = {
args: {
name: 'choice',
value: 'a',
label: 'Option A',
group: '',
},
};
export const Selected: Story = {
args: {
name: 'choice',
value: 'b',
label: 'Option B',
group: 'b',
},
};
export const Disabled: Story = {
args: {
name: 'choice',
value: 'c',
label: 'Option C (deaktiviert)',
group: '',
disabled: true,
},
};
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/** Gleicher name für alle Radios in einer Gruppe; bind:group vom Parent hält den gewählten value. */
let {
name = '',
value = '',
/** Gebundener Wert der Gruppe entspricht dem value des ausgewählten Radios. */
group = $bindable(''),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="radio"
{name}
{value}
bind:group={group}
{disabled}
class="h-5 w-5 rounded-full border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+41
View File
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Select from './Select.svelte';
const meta = {
title: 'UI/Select',
component: Select,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Dropdown-Auswahl. `options`: Array aus `{ value, label }`. `bind:value` für die gewählte value.',
},
},
},
} satisfies Meta<Select>;
export default meta;
type Story = StoryObj<typeof meta>;
const options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
{ value: 'c', label: 'Option C' },
];
export const Default: Story = {
args: {
label: 'Auswahl',
placeholder: 'Bitte wählen',
options,
value: '',
},
};
export const WithValue: Story = {
args: {
label: 'Auswahl',
options,
value: 'b',
},
};
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
/** @type {{ value: string; label: string }[]} */
let {
options = [],
value = $bindable(''),
label = '',
placeholder = 'Bitte wählen',
disabled = false,
} = $props();
const selectId = $derived(`select-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<label
for={selectId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
</label>
{/if}
<div class="relative">
<select
id={selectId}
bind:value
{disabled}
class="h-12 w-full appearance-none rounded-lg border border-stein-300 bg-white px-4 py-3 pr-10 text-base text-stein-800 outline-none hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100 disabled:bg-stein-100 disabled:opacity-60"
>
<option value="" disabled>{placeholder}</option>
{#each options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span
class="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stein-500"
aria-hidden="true"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import SidebarNav from './SidebarNav.svelte';
const meta = {
title: 'UI/SidebarNav',
component: SidebarNav,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Seitennavigation mit gruppierten Links. `groups`: Array aus `{ label, links: [{ href, label, active? }] }`.',
},
},
},
} satisfies Meta<SidebarNav>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
groups: [
{
label: 'Kategorie',
links: [
{ href: '#', label: 'Link 1' },
{ href: '#', label: 'Link 2', active: true },
{ href: '#', label: 'Link 3' },
],
},
{
label: 'Kategorie 2',
links: [
{ href: '#', label: 'Link 4' },
{ href: '#', label: 'Link 5' },
],
},
],
},
};
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* @type {{ label: string; links: { href: string; label: string; active?: boolean }[] }[]}
*/
let { groups = [] } = $props();
</script>
<nav aria-label="Seitennavigation" class="w-60 bg-stein-50 p-4">
{#each groups as group, i}
<div class="{i > 0 ? 'mt-6' : ''}">
<p
class="mb-2 text-xs font-medium uppercase tracking-wider text-stein-500"
>
{group.label}
</p>
<ul class="space-y-0">
{#each group.links as link}
<li>
<a
href={link.href}
class="block rounded-lg px-3 py-2 text-sm {link.active
? 'bg-wald-50 font-medium text-wald-500'
: 'text-stein-700 hover:bg-wald-50'}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
{/each}
</nav>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Slider from './Slider.svelte';
const meta = {
title: 'UI/Slider',
component: Slider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Schieberegler für numerische Werte. `min`, `max`, `value` (bindbar). Optionales Label.',
},
},
},
argTypes: {
value: { control: { type: 'number', min: 0, max: 100 } },
min: { control: 'number' },
max: { control: 'number' },
},
} satisfies Meta<Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Wert',
min: 0,
max: 100,
value: 50,
},
};
export const WithRange: Story = {
args: {
label: 'Jahre',
min: 1990,
max: 2030,
value: 2025,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
value: 25,
disabled: true,
},
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let {
min = 0,
max = 100,
value = $bindable(50),
label = '',
disabled = false,
} = $props();
const inputId = $derived(`slider-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<div class="mb-1.5 flex items-center justify-between">
<label for={inputId} class="text-sm font-medium text-stein-700">
{label}
</label>
<span class="text-sm font-semibold text-stein-700">{value}</span>
</div>
{/if}
<input
type="range"
id={inputId}
{min}
{max}
bind:value
{disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
class="h-5 w-5 accent-wald-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-wald-500 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-stein-200"
/>
<div class="mt-1 flex justify-between text-xs text-stein-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TabBar from './TabBar.svelte';
const meta = {
title: 'UI/TabBar',
component: TabBar,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Horizontale Tab-Leiste. `tabs`: Array aus `{ id, label }`. `selectedId` / `bind:selectedId` für aktiven Tab.',
},
},
},
argTypes: {
selectedId: { control: 'text' },
},
} satisfies Meta<TabBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
tabs: [
{ id: 'tab1', label: 'Tab 1' },
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
],
selectedId: 'tab2',
labelledBy: 'Inhalte',
},
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/** @type {{ id: string; label: string }[]} */
let {
tabs = [],
selectedId = $bindable(''),
labelledBy = '',
} = $props();
$effect(() => {
if (tabs.length && !selectedId) selectedId = tabs[0].id;
});
function selectTab(id: string) {
selectedId = id;
}
function handleKeydown(e: KeyboardEvent) {
const idx = tabs.findIndex((t) => t.id === selectedId);
if (e.key === 'ArrowRight' && idx < tabs.length - 1) {
e.preventDefault();
selectedId = tabs[idx + 1].id;
} else if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault();
selectedId = tabs[idx - 1].id;
}
}
</script>
<div
role="tablist"
aria-label={labelledBy || 'Tabs'}
class="flex h-12 border-b border-stein-200"
onkeydown={handleKeydown}
>
{#each tabs as tab}
<button
type="button"
role="tab"
aria-selected={selectedId === tab.id}
aria-controls="panel-{tab.id}"
id="tab-{tab.id}"
class="border-b-2 px-4 text-sm font-medium transition-colors {selectedId === tab.id
? 'border-wald-500 text-wald-600 font-semibold'
: 'border-transparent text-stein-500 hover:text-stein-700'}"
tabindex={selectedId === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
+52
View File
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TextInput from './TextInput.svelte';
const meta = {
title: 'UI/TextInput',
component: TextInput,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Textfeld mit Label, optionaler Hilfetext- und Fehleranzeige. Unterstützt type (text, email, password, search), required, disabled.',
},
},
},
argTypes: {
type: {
control: 'select',
options: ['text', 'email', 'password', 'search'],
},
},
} satisfies Meta<TextInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'E-Mail',
type: 'email',
placeholder: 'ihre@email.de',
required: true,
helpText: 'Wir geben Ihre Daten nicht weiter.',
},
};
export const WithError: Story = {
args: {
label: 'E-Mail',
type: 'email',
value: 'ungueltig',
error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
invalid: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
value: 'Nur Lesen',
disabled: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<input
{type}
{name}
{placeholder}
{required}
{disabled}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+34
View File
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Textarea from './Textarea.svelte';
const meta = {
title: 'UI/Textarea',
component: Textarea,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Mehrzeiliges Textfeld mit Label, optionaler Hilfetext- und Fehleranzeige. `bind:value` für zweiweite Bindung.',
},
},
},
} satisfies Meta<Textarea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Nachricht',
placeholder: 'Ihre Nachricht an uns...',
helpText: 'Optional. Max. 500 Zeichen.',
},
};
export const WithError: Story = {
args: {
label: 'Nachricht',
error: 'Dieses Feld ist erforderlich.',
invalid: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
rows = 4,
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<textarea
{name}
{placeholder}
{required}
{disabled}
{rows}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Toggle from './Toggle.svelte';
const meta = {
title: 'UI/Toggle',
component: Toggle,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Ein/Aus-Schalter mit optionalem Label. `checked` und `disabled` steuerbar.',
},
},
},
argTypes: {
checked: { control: 'boolean' },
},
} satisfies Meta<Toggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Off: Story = {
args: {
label: 'Aus',
checked: false,
},
};
export const On: Story = {
args: {
label: 'An',
checked: true,
},
};
export const NoLabel: Story = {
args: {
checked: false,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
checked: false,
disabled: true,
},
};
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center gap-2 {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<span
class="relative inline-block h-6 w-11 shrink-0 rounded-full transition-all duration-200 {checked ? 'bg-wald-500' : 'bg-stein-300'}"
role="switch"
aria-checked={checked}
>
<input
type="checkbox"
class="sr-only"
bind:checked
{disabled}
onkeydown={(e) => e.key === ' ' && (checked = !checked)}
/>
<span
class="absolute top-0.5 inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 {checked ? 'left-[22px] translate-x-0.5' : 'left-0.5'}"
/>
</span>
{#if label}
<span class="text-base text-stein-700">{label}</span>
{/if}
</label>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
content,
placement: preferredPlacement = "top",
children,
}: { content: string; placement?: "top" | "bottom"; children?: Snippet } = $props();
const TOOLTIP_GAP = 8;
const TOOLTIP_MAX_WIDTH_PX = 352; // 22rem
let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left
function updatePosition() {
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top;
const spaceBelow = vh - rect.bottom;
placement =
preferredPlacement === "top"
? spaceAbove >= spaceBelow
? "top"
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max(
TOOLTIP_GAP,
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
}
function handleShow() {
updatePosition();
showTooltip = true;
}
function handleHide() {
showTooltip = false;
leftPx = null;
}
const placementClasses = $derived(
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2",
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
);
</script>
<div
bind:this={wrapperEl}
role="group"
class="relative inline-flex focus-within:outline-none"
onmouseenter={handleShow}
onmouseleave={handleHide}
onfocusin={handleShow}
onfocusout={handleHide}
>
{@render children?.()}
{#if showTooltip}
<div
role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-md shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle}
>
{content}
</div>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TypographyShowcase from './TypographyShowcase.svelte';
const meta = {
title: 'Foundation/Typography',
component: TypographyShowcase,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Typo-Skala des Windwiderstand Design Systems: Inter (Primary), Lora (Zitate). Gewichte 300, 400, 500, 600, 700.',
},
},
},
} satisfies Meta<TypographyShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TypeScale: Story = {};
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
// Zeigt die Typo-Skala aus global.css (02-typography)
</script>
<div class="space-y-8 text-stein-800">
<section class="space-y-2">
<h1>Überschrift 1 (H1)</h1>
<p class="text-sm text-stein-500">1.75rem / 2.125rem · 700 · -0.015em</p>
</section>
<section class="space-y-2">
<h2>Überschrift 2 (H2)</h2>
<p class="text-sm text-stein-500">1.5rem / 1.875rem · 700 · -0.01em</p>
</section>
<section class="space-y-2">
<h3>Überschrift 3 (H3)</h3>
<p class="text-sm text-stein-500">1.25rem / 1.625rem · 600 · -0.005em</p>
</section>
<section class="space-y-2">
<h4>Überschrift 4 (H4)</h4>
<p class="text-sm text-stein-500">1.125rem / 1.5rem · 600</p>
</section>
<section class="space-y-2">
<p class="text-base">
Fließtext (Body): Inter, 1.125rem (18px), line-height 1.556. Erlaubte Gewichte: 300, 400, 500, 600, 700.
</p>
<p class="text-sm text-stein-500">--font-body · 1.125rem · 1.556</p>
</section>
<section class="space-y-2">
<p class="font-light">Leicht (300): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-normal">Normal (400): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-medium">Medium (500): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-semibold">Semibold (600): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-bold">Bold (700): Windwiderstand für mehr Artenvielfalt.</p>
</section>
<section class="space-y-2">
<p class="text-xl italic text-stein-700" style="font-family: var(--font-secondary)">
„Lora Variable für Zitate und sekundäre Texte.“
</p>
<p class="text-sm text-stein-500">--font-secondary · Lora Variable</p>
</section>
<section class="space-y-2">
<div class="pageTitle">
<h1>Page-Titel (Beispiel)</h1>
<h2>Unterzeile oder Teaser</h2>
</div>
<p class="text-sm text-stein-500">.pageTitle (wie windwiderstand.de)</p>
</section>
</div>
+18
View File
@@ -0,0 +1,18 @@
/**
* Windwiderstand UI Komponenten aus 04-components-a-navigation-input.md
* Einbau in die App erfolgt später.
*/
export { default as Header } from './Header.svelte';
export { default as TabBar } from './TabBar.svelte';
export { default as SidebarNav } from './SidebarNav.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as Button } from './Button.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as Radio } from './Radio.svelte';
export { default as Slider } from './Slider.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Tooltip } from './Tooltip.svelte';
+14 -1
View File
@@ -5,6 +5,7 @@ import { getPageSlugs, getPageBySlug } from "../lib/cms";
import type { PageEntry } from "../lib/cms";
import { resolveContentImages } from "../lib/rusty-image";
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks } from "../lib/blog-utils";
import { getTranslationBundleBySlug } from "../lib/cms";
import { PAGE_RESOLVE } from "../lib/constants";
export const prerender = true;
@@ -40,6 +41,9 @@ if (!page) {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
const translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
---
<Layout
@@ -49,8 +53,17 @@ await resolveSearchableTextBlocks(page, tagsMap);
subheadline={page.subheadline}
topFullwidthBanner={page.topFullwidthBanner}
showBannerInLayout={!!page.topFullwidthBanner}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
]}
>
<article>
<ContentRows client:load layout={page} class="mt-8" />
<ContentRows
client:load
layout={page}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
class="mt-8"
/>
</article>
</Layout>
+13 -1
View File
@@ -13,9 +13,13 @@ import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
} from "../lib/blog-utils";
import { getTranslationBundleBySlug } from "../lib/cms";
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE } from "../lib/constants";
let page: PageEntry | null = null;
let translationBundle: Awaited<
ReturnType<typeof getTranslationBundleBySlug>
> = null;
let cmsError: string | null = null;
let openApiTitle = "RustyCMS API";
let pages: PageEntry[] = [];
@@ -36,6 +40,9 @@ try {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
}
} catch (e) {
cmsError = e instanceof Error ? e.message : String(e);
@@ -53,7 +60,12 @@ try {
showBannerInLayout={!!page.topFullwidthBanner}
>
<article>
<ContentRows client:load layout={page} class="mt-8" />
<ContentRows
client:load
layout={page}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
class="mt-8"
/>
</article>
</Layout>
) : (
+14 -1
View File
@@ -19,6 +19,7 @@ import {
getPostImageUrl,
formatPostDate,
} from "../../lib/blog-utils";
import { getTranslationBundleBySlug } from "../../lib/cms";
import { POST_RESOLVE } from "../../lib/constants";
export const prerender = true;
@@ -52,6 +53,9 @@ await resolveContentImages(post);
const tagsMap = await resolvePostOverviewBlocks(post);
await resolveSearchableTextBlocks(post, tagsMap);
resolvePostTagsInPost(post, tagsMap);
const translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
const rawPostImageUrl = getPostImageUrl(post.postImage);
const postImageUrl = rawPostImageUrl
@@ -79,6 +83,11 @@ const contentHtml =
topFullwidthBanner={undefined}
showBannerInLayout={false}
image={postImageUrl ?? undefined}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
]}
>
<div class="md:flex gap-2 items-end">
{
@@ -119,7 +128,11 @@ const contentHtml =
set:html={contentHtml}
/>
) : (
<ContentRows client:load layout={post} />
<ContentRows
client:load
layout={post}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
/>
)
}
</article>
+4
View File
@@ -39,6 +39,10 @@ const pagePosts = paginate(filtered, 1, perPage);
<Layout
title="Beiträge Aktuelles"
description="Übersicht aller Beiträge und Meldungen."
breadcrumbItems={[
{ href: "/", label: "Start" },
{ label: "Beiträge" },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
+4
View File
@@ -51,6 +51,10 @@ const pagePosts = paginate(filtered, pageNum, perPage);
<Layout
title={`Beiträge Seite ${pageNum}`}
description="Übersicht aller Beiträge und Meldungen."
breadcrumbItems={[
{ href: "/", label: "Start" },
{ label: "Beiträge" },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
+5
View File
@@ -63,6 +63,11 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
<Layout
title={`Beiträge ${tagName}`}
description={`Beiträge zum Thema ${tagName}.`}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ label: tagName },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
@@ -76,6 +76,11 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
<Layout
title={`Beiträge ${tagName} (Seite ${pageNum})`}
description={`Beiträge zum Thema ${tagName}.`}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ label: tagName },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
+11 -9
View File
@@ -1,25 +1,27 @@
/**
* Dynamische robots.txt mit Sitemap-URL aus astro.config (site).
* Siehe: https://docs.astro.build/en/guides/integrations-guide/sitemap/#sitemap-link-in-robots-txt
* Ausgabe: /robots.txt
*/
import type { APIRoute } from "astro";
export const prerender = true;
function getRobotsTxt(): string {
const base = (import.meta.env.SITE || "").replace(/\/$/, "");
const sitemap = base ? `${base}/sitemap-index.xml` : "/sitemap-index.xml";
return [
const getRobotsTxt = (sitemapURL: URL) =>
[
"User-agent: *",
"Allow: /",
"",
`Sitemap: ${sitemap}`,
`Sitemap: ${sitemapURL.href}`,
].join("\n");
}
export function GET(): Response {
return new Response(getRobotsTxt(), {
export const GET: APIRoute = ({ site }) => {
const base = site ?? (import.meta.env.SITE as string | undefined) ?? "https://windwiderstand.de";
const sitemapURL = new URL("sitemap-index.xml", base);
return new Response(getRobotsTxt(sitemapURL), {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
};
+276 -63
View File
@@ -1,29 +1,120 @@
/* Windwiderstand Design System Fonts */
@import "@fontsource/inter/latin-300.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
@import "@fontsource/inter/latin-700.css";
@import "@fontsource/inter/latin-800.css";
@import "@fontsource/inter/latin-900.css";
@import "@fontsource-variable/lora";
@import "@fontsource-variable/rubik";
@import "tailwindcss";
:root {
--font-body:
"Rubik Variable", Arial, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--font-secondary: "Lora Variable", Georgia, serif;
/* ==========================================================================
Design Tokens Windwiderstand (01-colors, 02-typography, 06-tokens.json)
========================================================================== */
--color-font: #020617; /* slate-950 */
--color-font-highlight: #f87171; /* red-400 */
--color-navigation: #fff;
--background-color: #020617;
--color-text: #000;
--color-headings: #000;
--color-link: #15803d; /* green-700 */
--color-btn-bg: #15803d;
--color-btn-hover-bg: #166534; /* green-800 */
@theme {
/* Palette: Wald (Primary) */
--color-wald-50: #f0f5f1;
--color-wald-100: #d9e8dc;
--color-wald-200: #b3d1b9;
--color-wald-300: #7fb58a;
--color-wald-400: #4a9960;
--color-wald-500: #2d7a45;
--color-wald-600: #236335;
--color-wald-700: #1a4d28;
--color-wald-800: #12361c;
--color-wald-900: #0a2011;
/* Palette: Erde (Secondary) */
--color-erde-50: #faf6f1;
--color-erde-100: #f0e6d6;
--color-erde-200: #e0ccad;
--color-erde-300: #c9a87a;
--color-erde-400: #b08a52;
--color-erde-500: #8b6d3f;
--color-erde-600: #6e5530;
--color-erde-700: #524023;
--color-erde-800: #372b18;
--color-erde-900: #1c160c;
/* Palette: Himmel (Accent / Links) */
--color-himmel-50: #f2f5f7;
--color-himmel-100: #dce4ea;
--color-himmel-200: #b8c9d4;
--color-himmel-300: #8aaabb;
--color-himmel-400: #5e8ba2;
--color-himmel-500: #436e85;
--color-himmel-600: #34576a;
--color-himmel-700: #264150;
--color-himmel-800: #1a2c36;
--color-himmel-900: #0e181e;
/* Palette: Stein (Neutrals) */
--color-stein-0: #ffffff;
--color-stein-50: #f7f8f7;
--color-stein-100: #eceeed;
--color-stein-200: #d5d8d6;
--color-stein-300: #b0b5b2;
--color-stein-400: #868c89;
--color-stein-500: #636966;
--color-stein-600: #4a4f4c;
--color-stein-700: #333735;
--color-stein-800: #1f2221;
--color-stein-900: #0f1110;
/* Semantic: Success, Warning, Error, Info */
--color-success: var(--color-wald-500);
--color-success-subtle: var(--color-wald-50);
--color-warning: #a6780a;
--color-warning-subtle: #fdf8ec;
--color-error: #b53629;
--color-error-subtle: #fdf2f1;
--color-info: var(--color-himmel-500);
--color-info-subtle: var(--color-himmel-50);
}
:root {
/* Typography Inter (primary), Lora (quotes) */
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-secondary: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
/* Semantic (nur Light Theme) */
--bg-primary: #ffffff;
--bg-secondary: #f7f8f7;
--bg-tertiary: #eceeed;
--bg-elevated: #ffffff;
--text-primary: #1f2221;
--text-secondary: #636966;
--text-tertiary: #868c89;
--border-default: #d5d8d6;
--border-strong: #b0b5b2;
--accent: #2d7a45;
--accent-hover: #236335;
--link: #436e85;
/* Legacy / App-Aliase (weiterverwendet) */
--color-font: var(--text-primary);
--color-headings: var(--text-primary);
--color-font-highlight: var(--color-error);
--color-navigation: var(--color-stein-0);
--background-color: var(--bg-primary);
--color-text: var(--text-primary);
--color-link: var(--link);
--color-btn-bg: var(--accent);
--color-btn-hover-bg: var(--accent-hover);
--color-btn-txt: #fff;
--color-container-breakout: #000000;
/* Breakout-Bereich: Verlauf Wald-50 → Wald-100 */
--color-container-breakout: linear-gradient(
to bottom,
white,
var(--color-wald-50)
);
font-family: var(--font-body);
font-size: 18px;
line-height: 1.555;
font-size: 1.125rem; /* 18px Body Large laut Design System */
line-height: 1.556;
color: var(--color-font);
}
@@ -32,9 +123,14 @@ html {
}
main {
background: #fff;
background: var(--bg-primary);
}
/* ==========================================================================
Typography Type Scale (02-typography.md)
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Lora nur Zitate.
========================================================================== */
h1,
h2,
h3,
@@ -42,34 +138,82 @@ h4 {
color: var(--color-headings);
}
/* Mobile first, dann Tablet (768px), Desktop (1024px) */
h1 {
@apply text-3xl font-bold;
font-size: 1.75rem;
line-height: 2.125rem;
font-weight: 700;
letter-spacing: -0.015em;
}
h2 {
@apply font-bold uppercase text-2xl;
}
h3,
h4 {
@apply font-medium;
font-size: 1.375rem;
line-height: 1.75rem;
font-weight: 700;
letter-spacing: -0.01em;
}
h3 {
@apply text-xl;
font-size: 1.25rem;
line-height: 1.625rem;
font-weight: 600;
letter-spacing: -0.005em;
}
h4 {
@apply text-lg;
font-size: 1.125rem;
line-height: 1.5rem;
font-weight: 600;
}
/* Page-Titel wie www.windwiderstand.de */
@media (min-width: 768px) {
h1 {
font-size: 2.125rem;
line-height: 2.625rem;
}
h2 {
font-size: 1.5rem;
line-height: 2rem;
}
h3 {
font-size: 1.375rem;
line-height: 1.875rem;
}
h4 {
font-size: 1.125rem;
line-height: 1.625rem;
}
}
@media (min-width: 1024px) {
h1 {
font-size: 2.5rem;
line-height: 3rem;
}
h2 {
font-size: 1.75rem;
line-height: 2.25rem;
}
h3 {
font-size: 1.5rem;
line-height: 2rem;
}
}
/* Page-Titel (wie www.windwiderstand.de) */
.pageTitle h1 {
@apply text-2xl font-extrabold mb-2;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.pageTitle h2 {
@apply text-base font-medium normal-case;
font-size: 1rem;
line-height: 1.5rem;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
}
.pageTitle h1,
@@ -80,34 +224,54 @@ h4 {
}
.pageTitle strong {
@apply font-extrabold;
font-weight: 700;
}
@media (min-width: 768px) {
.pageTitle h1 {
@apply text-4xl;
font-size: 2.25rem;
line-height: 2.5rem;
}
.pageTitle h2 {
@apply text-2xl pl-1;
font-size: 1.5rem;
line-height: 2rem;
padding-left: 0.25rem;
}
}
@media (min-width: 1024px) {
.pageTitle h1 {
@apply text-6xl;
font-size: 3.75rem;
line-height: 1.14;
font-weight: 700;
letter-spacing: -0.02em;
}
.pageTitle h2 {
@apply text-3xl pl-1;
font-size: 1.875rem;
line-height: 2.25rem;
padding-left: 0.25rem;
}
}
/* Typo im Content */
/* Content-Links: Himmel (Design System Links) */
main a:not(.no-underline) {
@apply underline text-green-700 hover:text-green-800;
color: var(--color-link);
text-decoration: underline;
}
main a:not(.no-underline):hover {
color: var(--color-himmel-600);
}
/* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */
main a,
.markdown a {
overflow-wrap: break-word;
word-break: break-all;
}
main strong {
@apply font-semibold;
font-weight: 600;
}
main ul {
@@ -128,9 +292,17 @@ main ol li {
list-style-type: decimal;
}
/* Buttons */
/* Buttons Wald (Primary) */
.btn-primary {
@apply no-underline transition font-light p-2 px-4 rounded shadow-md;
display: inline-block;
text-decoration: none;
transition: background-color 0.2s ease;
font-weight: 300;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
background: var(--color-btn-bg);
color: var(--color-btn-txt);
}
@@ -139,9 +311,9 @@ main ol li {
background: var(--color-btn-hover-bg);
}
/* Content-Blöcke (wie www.windwiderstand.de) */
/* Content-Blöcke */
.content p {
@apply mb-4;
margin-bottom: 1rem;
}
.content h1,
@@ -151,63 +323,105 @@ main ol li {
margin-bottom: 0.5em;
}
/* Markdown-Content: Abstände, Listen, Bilder, Tabellen */
/* Markdown: Abstände, Listen, Bilder, Tabellen (Stein-Palette) */
.markdown {
min-width: 0;
overflow-x: auto;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown hr {
@apply mt-4 mb-3;
margin-top: 1rem;
margin-bottom: 0.75rem;
}
.markdown p,
.markdown li {
@apply my-0;
margin-top: 0;
margin-bottom: 0;
}
.markdown ul,
.markdown ol {
@apply my-0;
margin-top: 0;
margin-bottom: 0;
}
.markdown img {
@apply max-w-[400px] w-full;
max-width: 400px;
width: 100%;
}
.markdown table {
@apply w-full border-collapse text-left text-sm;
width: 100%;
min-width: max-content;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.markdown thead {
@apply bg-slate-100;
background: var(--color-stein-100);
}
.markdown th {
@apply px-3 py-2 font-semibold border border-slate-300;
padding: 0.5rem 0.75rem;
font-weight: 600;
border: 1px solid var(--color-stein-200);
}
.markdown td {
@apply px-3 py-2 border border-slate-300;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-stein-200);
}
.markdown tbody tr:nth-child(even) {
@apply bg-slate-50;
background: var(--color-stein-50);
}
.markdown tbody tr:hover {
@apply bg-slate-100/80;
background: var(--color-stein-100);
}
.markdown table a {
@apply text-green-700 underline underline-offset-2 break-all;
color: var(--color-link);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.markdown table a:hover {
@apply text-green-800;
color: var(--color-himmel-600);
}
/* Container wie Referenz (max-width Stufen) */
/* Code: Inline und Block (pre/code) lesbare Größe, Stein-Palette */
main code,
.content code,
.markdown code {
@apply font-mono bg-stein-100 text-stein-800 border border-stein-200;
font-size: 0.875em;
padding: 0.15em 0.4em;
}
main pre,
.content pre,
.markdown pre {
@apply mt-3 mb-4 overflow-x-auto rounded-md border border-stein-200 bg-stein-100 px-4 py-3 text-stein-800 leading-normal;
font-size: 0.8125rem;
}
main pre code,
.content pre code,
.markdown pre code {
@apply border-0 bg-transparent p-0 text-inherit;
font-size: 1em;
}
/* Container */
.container-custom {
width: 100%;
margin-left: auto;
@@ -240,19 +454,18 @@ main ol li {
}
}
/* Quote-Block: Rubik Variable */
/* Zitate: Lora (Design System nur für Zitate) */
[data-block-type="quote"] blockquote,
[data-block-type="quote"] blockquote p,
[data-block-type="quote"] blockquote cite {
font-family: var(--font-secondary);
}
/* Content-Rows: Abstand zwischen Zeilen (wie Referenz) */
.content-row + .content-row {
@apply mt-6;
margin-top: 1.5rem;
}
/* Footer-Links wie Referenz */
/* Footer */
footer a,
.content-footer a {
color: var(--color-link);
@@ -260,11 +473,11 @@ footer a,
footer a:hover,
.content-footer a:hover {
@apply text-green-800;
color: var(--color-himmel-600);
}
.container-breakout {
margin-left: calc(50% - 50vw + 0.01rem);
margin-right: calc(50% - 50vw + 0.01rem);
background-color: var(--color-container-breakout);
background: var(--color-container-breakout);
}
+1014 -39
View File
File diff suppressed because it is too large Load Diff