From 1f5454d04eee706cd66fa426fef7bcaa3288968f Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Fri, 27 Feb 2026 14:13:38 +0100 Subject: [PATCH] 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. --- .env.example | 13 + .firebaserc | 5 + .gitignore | 1 + .storybook/main.ts | 44 + .storybook/preview.ts | 27 + astro.config.mjs | 2 +- debug-storybook.log | 40 + firebase.json | 44 + package.json | 11 +- src/components/BlogOverview.svelte | 89 +- src/components/ContentRows.svelte | 17 +- .../ContentRowsWithClientSearch.astro | 121 ++ src/components/Pagination.svelte | 71 ++ src/components/Tag.svelte | 2 +- .../blocks/ImageGalleryBlock.svelte | 89 +- .../blocks/SearchableTextBlock.svelte | 115 +- .../blocks/YoutubeVideoBlock.svelte | 6 +- src/env.d.ts | 2 + src/layouts/Layout.astro | 58 +- src/lib/block-layout.ts | 14 +- src/lib/block-types.ts | 2 + src/lib/blog-utils.ts | 2 +- src/lib/cms-api.generated.ts | 6 +- src/lib/cms.ts | 52 + src/lib/constants.ts | 3 + src/lib/rusty-image.ts | 71 +- src/lib/ui/Badge.stories.ts | 76 ++ src/lib/ui/Badge.svelte | 57 + src/lib/ui/Breadcrumbs.stories.ts | 28 + src/lib/ui/Breadcrumbs.svelte | 41 + src/lib/ui/Button.stories.ts | 81 ++ src/lib/ui/Button.svelte | 46 + src/lib/ui/Checkbox.stories.ts | 43 + src/lib/ui/Checkbox.svelte | 23 + src/lib/ui/Colors.stories.ts | 22 + src/lib/ui/ColorsShowcase.svelte | 135 +++ src/lib/ui/Header.stories.ts | 34 + src/lib/ui/Header.svelte | 101 ++ src/lib/ui/Radio.stories.ts | 49 + src/lib/ui/Radio.svelte | 25 + src/lib/ui/Select.stories.ts | 41 + src/lib/ui/Select.svelte | 49 + src/lib/ui/SidebarNav.stories.ts | 40 + src/lib/ui/SidebarNav.svelte | 33 + src/lib/ui/Slider.stories.ts | 49 + src/lib/ui/Slider.svelte | 39 + src/lib/ui/TabBar.stories.ts | 33 + src/lib/ui/TabBar.svelte | 51 + src/lib/ui/TextInput.stories.ts | 52 + src/lib/ui/TextInput.svelte | 57 + src/lib/ui/Textarea.stories.ts | 34 + src/lib/ui/Textarea.svelte | 57 + src/lib/ui/Toggle.stories.ts | 49 + src/lib/ui/Toggle.svelte | 29 + src/lib/ui/Tooltip.svelte | 83 ++ src/lib/ui/Typography.stories.ts | 22 + src/lib/ui/TypographyShowcase.svelte | 55 + src/lib/ui/index.ts | 18 + src/pages/[slug].astro | 15 +- src/pages/index.astro | 14 +- src/pages/post/[slug].astro | 15 +- src/pages/posts/index.astro | 4 + src/pages/posts/page/[page].astro | 4 + src/pages/posts/tag/[tag].astro | 5 + src/pages/posts/tag/[tag]/page/[page].astro | 5 + src/pages/robots.txt.ts | 20 +- src/styles/global.css | 339 +++++- yarn.lock | 1053 ++++++++++++++++- 68 files changed, 3668 insertions(+), 265 deletions(-) create mode 100644 .firebaserc create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.ts create mode 100644 debug-storybook.log create mode 100644 firebase.json create mode 100644 src/components/ContentRowsWithClientSearch.astro create mode 100644 src/components/Pagination.svelte create mode 100644 src/lib/ui/Badge.stories.ts create mode 100644 src/lib/ui/Badge.svelte create mode 100644 src/lib/ui/Breadcrumbs.stories.ts create mode 100644 src/lib/ui/Breadcrumbs.svelte create mode 100644 src/lib/ui/Button.stories.ts create mode 100644 src/lib/ui/Button.svelte create mode 100644 src/lib/ui/Checkbox.stories.ts create mode 100644 src/lib/ui/Checkbox.svelte create mode 100644 src/lib/ui/Colors.stories.ts create mode 100644 src/lib/ui/ColorsShowcase.svelte create mode 100644 src/lib/ui/Header.stories.ts create mode 100644 src/lib/ui/Header.svelte create mode 100644 src/lib/ui/Radio.stories.ts create mode 100644 src/lib/ui/Radio.svelte create mode 100644 src/lib/ui/Select.stories.ts create mode 100644 src/lib/ui/Select.svelte create mode 100644 src/lib/ui/SidebarNav.stories.ts create mode 100644 src/lib/ui/SidebarNav.svelte create mode 100644 src/lib/ui/Slider.stories.ts create mode 100644 src/lib/ui/Slider.svelte create mode 100644 src/lib/ui/TabBar.stories.ts create mode 100644 src/lib/ui/TabBar.svelte create mode 100644 src/lib/ui/TextInput.stories.ts create mode 100644 src/lib/ui/TextInput.svelte create mode 100644 src/lib/ui/Textarea.stories.ts create mode 100644 src/lib/ui/Textarea.svelte create mode 100644 src/lib/ui/Toggle.stories.ts create mode 100644 src/lib/ui/Toggle.svelte create mode 100644 src/lib/ui/Tooltip.svelte create mode 100644 src/lib/ui/Typography.stories.ts create mode 100644 src/lib/ui/TypographyShowcase.svelte create mode 100644 src/lib/ui/index.ts diff --git a/.env.example b/.env.example index 42e8ca1..0fcef07 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..2694c41 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "windwiderstand" + } +} diff --git a/.gitignore b/.gitignore index c234678..4dcb20c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ pnpm-debug.log* # jetbrains setting folder .idea/ +.firebase/ .history/ public/images/transformed/ public/pagefind \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..6526098 --- /dev/null +++ b/.storybook/main.ts @@ -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; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..7138a95 --- /dev/null +++ b/.storybook/preview.ts @@ -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; diff --git a/astro.config.mjs b/astro.config.mjs index b1aa7b3..e951817 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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, diff --git a/debug-storybook.log b/debug-storybook.log new file mode 100644 index 0000000..4f6fc99 --- /dev/null +++ b/debug-storybook.log @@ -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. (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 \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..69b0df0 --- /dev/null +++ b/firebase.json @@ -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" + } + ] + } + ] + } +} diff --git a/package.json b/package.json index 86121ff..aa772e4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/BlogOverview.svelte b/src/components/BlogOverview.svelte index 93d50a9..6967fa6 100644 --- a/src/components/BlogOverview.svelte +++ b/src/components/BlogOverview.svelte @@ -1,6 +1,7 @@
@@ -82,33 +59,12 @@
{/if} - {#if showPagination} -
-
-
- {#if hasPrev} - - ← - - {/if} - {#each pageNumbers as num} - - {num} - - {/each} - {#if hasNext} - - → - - {/if} -
-
-
- {/if} +
{#each posts as post} @@ -121,31 +77,12 @@
- {#if showPagination} -
-
- {#if hasPrev} - - ← - - {/if} - {#each pageNumbers as num} - - {num} - - {/each} - {#if hasNext} - - → - - {/if} -
-
- {/if} +
diff --git a/src/components/ContentRows.svelte b/src/components/ContentRows.svelte index c20c22f..4b17d41 100644 --- a/src/components/ContentRows.svelte +++ b/src/components/ContentRows.svelte @@ -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"} {:else if blockType(item) === "searchable_text"} - + {:else}
Unbekannter Block: {blockType(item)} @@ -119,12 +126,12 @@ {/snippet} {#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
-
+
{@render blockContent()}
-
+
{:else} {@render blockContent()} {/if} diff --git a/src/components/ContentRowsWithClientSearch.astro b/src/components/ContentRowsWithClientSearch.astro new file mode 100644 index 0000000..5a9726e --- /dev/null +++ b/src/components/ContentRowsWithClientSearch.astro @@ -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 { + return typeof item === "object" && item !== null; +} + +function blockType(item: Record): 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).layout as + | { breakout?: boolean } + | undefined; + return layout?.breakout === true; +} +--- + +
+ { + rows.map((row) => + Array.isArray(row.content) && row.content.length > 0 ? ( +
+ {row.content.map((item) => { + const content = isResolvedBlock(item) ? ( + blockType(item) === "searchable_text" ? ( + + ) : blockType(item) === "iframe" || + blockType(item) === "image_gallery" ? ( + + ) : ( + + ) + ) : null; + const wrapped = + content && layoutBreakout(item) ? ( +
+
+ {content} +
+
+ ) : ( + content + ); + return wrapped; + })} +
+ ) : null, + ) + } +
diff --git a/src/components/Pagination.svelte b/src/components/Pagination.svelte new file mode 100644 index 0000000..1ed14fa --- /dev/null +++ b/src/components/Pagination.svelte @@ -0,0 +1,71 @@ + + +{#if showPagination} +
+
+ {#if hasPrev} + + + {/if} + {#each pageNumbers as num} + + {num} + + {/each} + {#if hasNext} + + + {/if} +
+
+{/if} diff --git a/src/components/Tag.svelte b/src/components/Tag.svelte index 61b9800..8c06669 100644 --- a/src/components/Tag.svelte +++ b/src/components/Tag.svelte @@ -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(() => { diff --git a/src/components/blocks/ImageGalleryBlock.svelte b/src/components/blocks/ImageGalleryBlock.svelte index 7bd2453..75f155d 100644 --- a/src/components/blocks/ImageGalleryBlock.svelte +++ b/src/components/blocks/ImageGalleryBlock.svelte @@ -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; + }
@@ -50,22 +105,34 @@ {/if} {#if withUrl.length === 0} -

Keine Bilder in der Galerie.

+

Keine Bilder in der Galerie.

{:else if withUrl.length === 1}
{withUrl[0].img.description {#if withUrl[0].img.description} -
{withUrl[0].img.description}
+
{withUrl[0].img.description}
{/if}
{:else} -
-
+
+
{#key currentIndex} {@const current = withUrl[currentIndex]}
{#if current.img.description}
{current.img.description}
@@ -91,7 +158,7 @@ {/each} diff --git a/src/components/blocks/SearchableTextBlock.svelte b/src/components/blocks/SearchableTextBlock.svelte index f3658d5..66c168a 100644 --- a/src/components/blocks/SearchableTextBlock.svelte +++ b/src/components/blocks/SearchableTextBlock.svelte @@ -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(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 ). */ function highlightInText(text: string, term: string): string { @@ -106,35 +147,35 @@
{#if block.title} -

{block.title}

+

{block.title}

{/if} {#if descriptionHtml} -
{@html descriptionHtml}
+
{@html descriptionHtml}
{/if}
-
-
- - - Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen. - -
-
- -
+
-
+
+ + + (searchQuery = (e.target as HTMLInputElement).value)} aria-label="Suche in Titeln und Inhalten" @@ -142,14 +183,14 @@ {#if searchQuery} {/if} -
{#if allTags.length > 0}
@@ -174,7 +215,7 @@
-
+
{#if visibleFragments.length === 0} Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort. {:else} @@ -186,19 +227,37 @@ {/if}
-
- {#each visibleFragments as fragment} -
- -
{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}
+
+ {#each visibleFragments as fragment, i} +
+ +
+
{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}
+
{#if fragment.tags.length > 0} - + {/if} -
-
+
+
+ +
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
diff --git a/src/components/blocks/YoutubeVideoBlock.svelte b/src/components/blocks/YoutubeVideoBlock.svelte index f4aa088..d14ede9 100644 --- a/src/components/blocks/YoutubeVideoBlock.svelte +++ b/src/components/blocks/YoutubeVideoBlock.svelte @@ -14,7 +14,7 @@
{#if block.youtubeId} -
+
{#if block.description} -

{block.description}

+
{block.description}
{/if} {:else} -

YouTube-Video-ID fehlt.

+
YouTube-Video-ID fehlt.
{/if}
diff --git a/src/env.d.ts b/src/env.d.ts index fbbfa31..f3a444b 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -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 { diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 8a3b7bb..bf1afee 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -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 && } + { + socialImage && ( + <> + + + + ) + } + + @@ -367,7 +392,25 @@ const titleSubheadlineHtml = ) } {socialImage && } + + + { + import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && + import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && ( + diff --git a/src/lib/block-layout.ts b/src/lib/block-layout.ts index ce7550a..e497afc 100644 --- a/src/lib/block-layout.ts +++ b/src/lib/block-layout.ts @@ -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, diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 553ec98..8413ce1 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -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). */ diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index 57fd950..253a456 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -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, { diff --git a/src/lib/cms-api.generated.ts b/src/lib/cms-api.generated.ts index 470960a..8f14e30 100644 --- a/src/lib/cms-api.generated.ts +++ b/src/lib/cms-api.generated.ts @@ -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 (1–100) */ + /** @description Quality 1–100 for JPEG and WebP (lossy) */ quality?: number; }; header?: never; diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 66bd60a..a00c25e 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -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 { + 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; +}; + +/** + * 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 { + 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; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9b260bb..e8fe477 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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"; diff --git a/src/lib/rusty-image.ts b/src/lib/rusty-image.ts index 9ca471a..ba73ca2 100644 --- a/src/lib/rusty-image.ts +++ b/src/lib/rusty-image.ts @@ -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); + } + } } } } diff --git a/src/lib/ui/Badge.stories.ts b/src/lib/ui/Badge.stories.ts new file mode 100644 index 0000000..0ddc210 --- /dev/null +++ b/src/lib/ui/Badge.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/src/lib/ui/Badge.svelte b/src/lib/ui/Badge.svelte new file mode 100644 index 0000000..3b95273 --- /dev/null +++ b/src/lib/ui/Badge.svelte @@ -0,0 +1,57 @@ + + +{#if href} + + {label} + +{:else if onclick} + +{:else} + {label} +{/if} diff --git a/src/lib/ui/Breadcrumbs.stories.ts b/src/lib/ui/Breadcrumbs.stories.ts new file mode 100644 index 0000000..474d8a4 --- /dev/null +++ b/src/lib/ui/Breadcrumbs.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + items: [ + { href: '/', label: 'Startseite' }, + { href: '/aktuelles', label: 'Aktuelles' }, + { label: 'Artikel-Titel' }, + ], + }, +}; diff --git a/src/lib/ui/Breadcrumbs.svelte b/src/lib/ui/Breadcrumbs.svelte new file mode 100644 index 0000000..7daf42f --- /dev/null +++ b/src/lib/ui/Breadcrumbs.svelte @@ -0,0 +1,41 @@ + + + diff --git a/src/lib/ui/Button.stories.ts b/src/lib/ui/Button.stories.ts new file mode 100644 index 0000000..1f8570c --- /dev/null +++ b/src/lib/ui/Button.stories.ts @@ -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 diff --git a/src/lib/ui/Checkbox.stories.ts b/src/lib/ui/Checkbox.stories.ts new file mode 100644 index 0000000..e8cfead --- /dev/null +++ b/src/lib/ui/Checkbox.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/src/lib/ui/Checkbox.svelte b/src/lib/ui/Checkbox.svelte new file mode 100644 index 0000000..470fe48 --- /dev/null +++ b/src/lib/ui/Checkbox.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/ui/Colors.stories.ts b/src/lib/ui/Colors.stories.ts new file mode 100644 index 0000000..777db00 --- /dev/null +++ b/src/lib/ui/Colors.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +export const Palettes: Story = {}; diff --git a/src/lib/ui/ColorsShowcase.svelte b/src/lib/ui/ColorsShowcase.svelte new file mode 100644 index 0000000..688769a --- /dev/null +++ b/src/lib/ui/ColorsShowcase.svelte @@ -0,0 +1,135 @@ + + +
+ {#each palettes as palette} +
+

{palette.name}

+
+ {#each palette.tokens as t} + {@const hex = paletteHex[palette.prefix]?.[t]} +
+
+ {t} +
+ {/each} +
+
+ {/each} + +
+

Semantik (Success, Warning, Error, Info)

+
+ {#each semanticHex as s} +
+
+
+ {s.name} + + subtle +
+ {/each} +
+
+ +
+

Semantik & App ( :root )

+
+ {#each appColors as a} +
+
+ {a.name} + {a.var} +
+ {/each} +
+
+
diff --git a/src/lib/ui/Header.stories.ts b/src/lib/ui/Header.stories.ts new file mode 100644 index 0000000..f1bcbfd --- /dev/null +++ b/src/lib/ui/Header.stories.ts @@ -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
; + +export default meta; +type Story = StoryObj; + +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', + }, +}; diff --git a/src/lib/ui/Header.svelte b/src/lib/ui/Header.svelte new file mode 100644 index 0000000..0cdefab --- /dev/null +++ b/src/lib/ui/Header.svelte @@ -0,0 +1,101 @@ + + + + Zum Inhalt springen + +
+ + + {#if mobileMenuOpen} + + {/if} +
diff --git a/src/lib/ui/Radio.stories.ts b/src/lib/ui/Radio.stories.ts new file mode 100644 index 0000000..a1e230c --- /dev/null +++ b/src/lib/ui/Radio.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/src/lib/ui/Radio.svelte b/src/lib/ui/Radio.svelte new file mode 100644 index 0000000..b3868c1 --- /dev/null +++ b/src/lib/ui/Radio.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/ui/Select.stories.ts b/src/lib/ui/Select.stories.ts new file mode 100644 index 0000000..7554dad --- /dev/null +++ b/src/lib/ui/Select.stories.ts @@ -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 + + {#each options as opt} + + {/each} + + +
+
diff --git a/src/lib/ui/SidebarNav.stories.ts b/src/lib/ui/SidebarNav.stories.ts new file mode 100644 index 0000000..27bfcfb --- /dev/null +++ b/src/lib/ui/SidebarNav.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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' }, + ], + }, + ], + }, +}; diff --git a/src/lib/ui/SidebarNav.svelte b/src/lib/ui/SidebarNav.svelte new file mode 100644 index 0000000..4c55f29 --- /dev/null +++ b/src/lib/ui/SidebarNav.svelte @@ -0,0 +1,33 @@ + + + diff --git a/src/lib/ui/Slider.stories.ts b/src/lib/ui/Slider.stories.ts new file mode 100644 index 0000000..a3fdb43 --- /dev/null +++ b/src/lib/ui/Slider.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/src/lib/ui/Slider.svelte b/src/lib/ui/Slider.svelte new file mode 100644 index 0000000..fe43d48 --- /dev/null +++ b/src/lib/ui/Slider.svelte @@ -0,0 +1,39 @@ + + +
+ {#if label} +
+ + {value} +
+ {/if} + +
+ {min} + {max} +
+
diff --git a/src/lib/ui/TabBar.stories.ts b/src/lib/ui/TabBar.stories.ts new file mode 100644 index 0000000..79b6b16 --- /dev/null +++ b/src/lib/ui/TabBar.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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', + }, +}; diff --git a/src/lib/ui/TabBar.svelte b/src/lib/ui/TabBar.svelte new file mode 100644 index 0000000..91b4204 --- /dev/null +++ b/src/lib/ui/TabBar.svelte @@ -0,0 +1,51 @@ + + +
+ {#each tabs as tab} + + {/each} +
diff --git a/src/lib/ui/TextInput.stories.ts b/src/lib/ui/TextInput.stories.ts new file mode 100644 index 0000000..2de9c04 --- /dev/null +++ b/src/lib/ui/TextInput.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/src/lib/ui/TextInput.svelte b/src/lib/ui/TextInput.svelte new file mode 100644 index 0000000..45f88d2 --- /dev/null +++ b/src/lib/ui/TextInput.svelte @@ -0,0 +1,57 @@ + + +
+ {#if label} + + {/if} + + {#if error} + + {:else if helpText} +

+ {helpText} +

+ {/if} +
diff --git a/src/lib/ui/Textarea.stories.ts b/src/lib/ui/Textarea.stories.ts new file mode 100644 index 0000000..20d0a4d --- /dev/null +++ b/src/lib/ui/Textarea.stories.ts @@ -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