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