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