34 changed files with 585 additions and 132 deletions
@@ -18,6 +18,10 @@ jobs:
run: | run: |
npm ci npm ci
npm run build npm run build
node scripts/check-bundle.mjs
test -s dist/index.html
test -d dist/assets
test -n "$(find dist/assets -type f -print -quit)"
- name: Copy project files to VM - name: Copy project files to VM
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
@@ -25,7 +29,7 @@ jobs:
host: ${{ secrets.DEPLOY_SSH_HOST }} host: ${{ secrets.DEPLOY_SSH_HOST }}
username: ${{ secrets.DEPLOY_SSH_USER }} username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "Caddyfile,*,dist/**,dist/.*" source: "Caddyfile,docker-compose.yml,Dockerfile,server/**,package.json,package-lock.json,dist/**,dist/.*"
target: "/opt/orchestrator/privat/CV/andreknie.de" target: "/opt/orchestrator/privat/CV/andreknie.de"
- name: Execute Deployment on VM - name: Execute Deployment on VM
@@ -35,6 +39,7 @@ jobs:
username: ${{ secrets.DEPLOY_SSH_USER }} username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | script: |
set -Eeuo pipefail
cd /opt/orchestrator/privat/CV/andreknie.de cd /opt/orchestrator/privat/CV/andreknie.de
# 1. Sichere .env aus den Gitea Secrets generieren # 1. Sichere .env aus den Gitea Secrets generieren
@@ -55,15 +60,37 @@ jobs:
HASH_SALT=${{ secrets.UMAMI_HASH_SALT }} HASH_SALT=${{ secrets.UMAMI_HASH_SALT }}
EOF EOF
# 2. Frontend-Build in den Caddy-Ordner verschieben # 2. Vollständiges Frontend als begrenztes Release vorbereiten.
mkdir -p site # Die Release-ID stammt aus dem Commit und bleibt rückverfolgbar.
# Wenn dist existiert, kopiere den Inhalt test -s dist/index.html
if [ -d "dist" ]; then test -d dist/assets
cp -r dist/* site/ || true release_id="${{ github.sha }}"
cp -r dist/.* site/ 2>/dev/null || true test -n "$release_id"
else case "$release_id" in
echo "FEHLER: dist Ordner existiert nicht!" (*[!A-Za-z0-9._-]*) echo "Ungültige Release-ID"; exit 1 ;;
esac
release_dir="releases/release-${release_id}"
previous_dir="releases/previous"
mkdir -p releases
if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi
mkdir -p "$release_dir"
cp -a dist/. "$release_dir/"
test -s "$release_dir/index.html"
test -n "$(find "$release_dir/assets" -type f -print -quit)"
rollback() {
if [ -d "$previous_dir" ]; then
rm -rf -- site
mv -- "$previous_dir" site
echo "Rollback auf das vorherige Release ausgeführt."
fi
}
trap rollback ERR
if [ -e site ]; then
rm -rf -- "$previous_dir"
mv -- site "$previous_dir"
fi fi
mv -- "$release_dir" site
# Debugging: Zeige an, ob die Dateien wirklich da sind # Debugging: Zeige an, ob die Dateien wirklich da sind
echo "INHALT VON SITE:" echo "INHALT VON SITE:"
@@ -77,3 +104,8 @@ jobs:
docker compose exec -T caddy grep -n "Content-Security-Policy" /etc/caddy/Caddyfile docker compose exec -T caddy grep -n "Content-Security-Policy" /etc/caddy/Caddyfile
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
docker compose up -d --force-recreate caddy docker compose up -d --force-recreate caddy
trap - ERR
# Abgebrochene temporäre Releases entfernen. Neben site bleibt
# höchstens ein klar benannter Rollback-Stand erhalten.
find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' -exec rm -rf -- {} +
@@ -0,0 +1,21 @@
---
title: '049: KI-Maskenball'
date: 2026-08-02T00:00:00.000Z
image: /images/kniepunkt/049-cover.webp
---
# 049: KI-Maskenball
Der digitale Maskenball ist in vollem Gange und das Silicon Valley unterbietet sich gerade mit Lockangeboten an der Abendkasse. Anthropic hat diese Woche den Eintritt für die VIP-Tanzfläche halbiert und bittet mit dem neuen Spitzenmodell Claude Opus 5 zum extrem günstigen Tänzchen. Der deutsche Mittelstand und unzählige Entwickler stürmen das Parkett, berauscht von der brillanten Geschwindigkeit und dem unschlagbaren Preis. Doch mitten im Walzer merken sie, dass die Geldbörse weg ist. Wegen eines banalen, vergessenen HTML-Tags in der Teilen-Funktion spülte Claude diese Woche abertausende intime Chats, sensible Firmengeheimnisse und Quellcodes ungefiltert in die öffentlichen Indizes der Suchmaschinen. Das ist der wahre Preis des billigen Eintritts: Amerikanische KI-Dienste sind super günstig, super schnell und eben oft auch super unsouverän. Blindes Vertrauen kostet uns am Ende das eigene Geschäftsgeheimnis.
Wie gut, dass Europa genau jetzt das Licht anmacht und neue Hausregeln durchsetzt. Pünktlich heute zum 2. August schreitet der EU AI Act als strenger Türsteher ein und verteilt verpflichtende Namensschilder. Durch die neuen Transparenzpflichten herrscht ab sofort Offenbarungspflicht: Chatbots müssen sich klar als Maschinen zu erkennen geben. Während die US-Konzerne nicht einmal kontrollieren können, wem sie unsere Daten auf dem Marktplatz präsentieren, zwingen wir die Algorithmen nun aus der Anonymität. Es entbehrt nicht einer gewissen Ironie, dass wir ausgerechnet der Technologie, die uns im Internet die Realität raubt, nun per Gesetz die Maske vom Gesicht reißen müssen.
Doch Regulierung allein reicht nicht, wir müssen auch unser eigenes Verhalten hinterfragen. Der deutsche Wissenschaftsrat forderte kürzlich völlig zu Recht unsere „intellektuelle Souveränität“ im Umgang mit KI ein. Denn wer das kritische Denken an der kalifornischen Garderobe abgibt, hat das Recht verwirkt, sich hinterher über mangelnde Geheimhaltung zu beschweren. Und selbst wenn Vertraulichkeit in den AGBs steht: Versuchen Sie mal, einen Tech-Giganten aus San Francisco vor einem deutschen Landgericht auf Datenschutz zu verklagen! Wir haben eine bessere Alternative. Fast eine Milliarde Euro Wagniskapital sind im ersten Quartal in deutsche KI-Start-ups geflossen. Wenn wir dieses Kapital nutzen, um souveräne, europäische Modelle zu bauen, hat das vielleicht ein etwas höheres, aber ehrliches Preisschild. Dafür können wir in unserem eigenen Ballsaal endlich die Masken abnehmen, ohne Angst haben zu müssen, dass die ganze Welt zuschaut.
**Zum Weiterlesen:**
- **Der nackte Tanz:** Claude-Share-Links tauchen aufgrund fehlendem noindex-Schutz massenhaft in Suchmaschinen auf. [The Decoder](https://the-decoder.de/claude-datenpanne-geteilte-chats-tauchten-in-suchmaschinen-auf/)
- **Der halbe Eintritt:** Anthropic veröffentlicht das leistungsstarke und günstige Coder-Modell Claude Opus 5. [Anthropic](https://www.anthropic.com/news/claude-opus-5)
- **Namensschilder per Gesetz:** Ab 2. August 2026 greifen die Transparenzpflichten für Chatbots und generative KI nach dem AI Act. [Artificial Intelligence Act](https://artificialintelligenceact.eu/de/transparency-rules-article-50/)
- **Intellektuelle Souveränität:** Wissenschaftsrat warnt vor bequemer Vereinfachung durch generative KI. [Forschung & Lehre](https://www.forschung-und-lehre.de/politik/wr-spricht-sich-gegen-bequeme-vereinfachung-durch-ki-aus-7781)
- **Geld für den eigenen Ballsaal:** 967 Millionen Euro Wagniskapital flossen in Q1 2026 in heimische KI-Start-ups. [KfW](https://www.kfw.de/%C3%9Cber-die-KfW/Newsroom/Aktuelles/Pressemitteilungen-Details_892160.html)
@@ -7,8 +7,13 @@ import * as yaml from 'js-yaml'
const CONTENT_DIR = resolve(process.cwd(), 'content') const CONTENT_DIR = resolve(process.cwd(), 'content')
const VIRTUAL_MODULE_ID = 'virtual:content' const VIRTUAL_MODULE_ID = 'virtual:content'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
const DETAIL_LOADERS_MODULE_ID = 'virtual:content-detail-loaders'
const RESOLVED_DETAIL_LOADERS_MODULE_ID = '\0' + DETAIL_LOADERS_MODULE_ID
const DETAIL_MODULE_PREFIX = 'virtual:content-detail/'
const RESOLVED_DETAIL_MODULE_PREFIX = '\0' + DETAIL_MODULE_PREFIX
const DETAIL_TYPES = ['posts', 'kniepunkt']
function readContentDir(subdir) { function readContentDir(subdir, includeBodies = false) {
const dir = join(CONTENT_DIR, subdir) const dir = join(CONTENT_DIR, subdir)
if (!existsSync(dir)) return [] if (!existsSync(dir)) return []
@@ -22,10 +27,12 @@ function readContentDir(subdir) {
if (ext === '.md') { if (ext === '.md') {
const raw = readFileSync(filepath, 'utf-8') const raw = readFileSync(filepath, 'utf-8')
const { data, content } = matter(raw) const { data, content } = matter(raw)
const body = renderMarkdownContent(content)
const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 320)
return { return {
...data, ...data,
slug, slug,
body: renderMarkdownContent(content), ...(includeBodies ? { body } : { searchText }),
_source: `${subdir}/${filename}` _source: `${subdir}/${filename}`
} }
} }
@@ -60,15 +67,15 @@ function readMetaFile(filename) {
return yaml.load(raw) return yaml.load(raw)
} }
function loadAllContent() { function loadAllContent(includeBodies = false) {
return { return {
posts: readContentDir('posts'), posts: readContentDir('posts', includeBodies),
kniepunkt: readContentDir('kniepunkt'), kniepunkt: readContentDir('kniepunkt', includeBodies),
podcast: readContentDir('podcast'), podcast: readContentDir('podcast', includeBodies),
talks: readContentDir('talks'), talks: readContentDir('talks', includeBodies),
consulting: readContentDir('consulting'), consulting: readContentDir('consulting', includeBodies),
resources: readContentDir('resources'), resources: readContentDir('resources', includeBodies),
events: readContentDir('events'), events: readContentDir('events', includeBodies),
meta: { meta: {
profile: readMetaFile('profile.yaml'), profile: readMetaFile('profile.yaml'),
stats: readMetaFile('stats.yaml'), stats: readMetaFile('stats.yaml'),
@@ -82,25 +89,57 @@ function loadAllContent() {
} }
} }
function createDetailLoadersModule() {
const typeEntries = DETAIL_TYPES.map(type => {
const loaders = readContentDir(type)
.map(item => `${JSON.stringify(item.slug)}: () => import(${JSON.stringify(`${DETAIL_MODULE_PREFIX}${type}/${item.slug}`)})`)
.join(',\n')
return `${JSON.stringify(type)}: {\n${loaders}\n}`
})
return `export default {\n${typeEntries.join(',\n')}\n}`
}
function loadDetailModule(id) {
const relativeId = id.slice(RESOLVED_DETAIL_MODULE_PREFIX.length)
const separator = relativeId.indexOf('/')
if (separator < 1) return null
const type = relativeId.slice(0, separator)
const slug = relativeId.slice(separator + 1)
if (!DETAIL_TYPES.includes(type)) return null
const item = readContentDir(type, true).find(candidate => candidate.slug === slug)
return item ? `export default ${JSON.stringify(item)}` : null
}
export default function contentPlugin() { export default function contentPlugin() {
return { return {
name: 'vite-plugin-content', name: 'vite-plugin-content',
resolveId(id) { resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
if (id === DETAIL_LOADERS_MODULE_ID) return RESOLVED_DETAIL_LOADERS_MODULE_ID
if (id.startsWith(DETAIL_MODULE_PREFIX)) return `\0${id}`
}, },
load(id) { load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) { if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const content = loadAllContent() const content = loadAllContent()
return `export default ${JSON.stringify(content)}` return `export default ${JSON.stringify(content)}`
} }
if (id === RESOLVED_DETAIL_LOADERS_MODULE_ID) return createDetailLoadersModule()
if (id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX)) return loadDetailModule(id)
}, },
handleHotUpdate({ file, server }) { handleHotUpdate({ file, server }) {
if (file.includes('content')) { if (file.includes('content')) {
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID) const modules = [
if (mod) { server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID),
server.moduleGraph.invalidateModule(mod) server.moduleGraph.getModuleById(RESOLVED_DETAIL_LOADERS_MODULE_ID),
return [mod] ...Array.from(server.moduleGraph.idToModuleMap.entries())
} .filter(([id]) => id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX))
.map(([, module]) => module),
].filter(Boolean)
modules.forEach(module => server.moduleGraph.invalidateModule(module))
return modules
} }
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

@@ -0,0 +1,27 @@
import { readdirSync, statSync } from 'node:fs'
import { basename, join } from 'node:path'
const assetsDir = join(process.cwd(), 'dist', 'assets')
const files = readdirSync(assetsDir)
const entry = files.find(file => /^index-[^/]+\.js$/.test(file))
const detailSlugs = ['posts', 'kniepunkt'].flatMap(type =>
readdirSync(join(process.cwd(), 'content', type))
.filter(file => file.endsWith('.md'))
.map(file => basename(file, '.md')),
)
const missingDetailChunks = detailSlugs.filter(
slug => !files.some(file => file.startsWith(`${slug}-`) && file.endsWith('.js')),
)
const maxInitialBytes = 400 * 1024
if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.')
if (missingDetailChunks.length > 0) {
throw new Error(`Fehlende Content-Detail-Chunks: ${missingDetailChunks.join(', ')}`)
}
const entryBytes = statSync(join(assetsDir, entry)).size
if (entryBytes > maxInitialBytes) {
throw new Error(`Initialer JS-Chunk ist zu groß: ${entryBytes} Bytes (Limit ${maxInitialBytes}).`)
}
console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunks=${detailSlugs.length}`)
+40 -33
View File
@@ -1,25 +1,30 @@
import React from 'react' import React, { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import Navigation from './components/Navigation' import Navigation from './components/Navigation'
import Footer from './components/Footer' import Footer from './components/Footer'
import BackgroundPattern from './components/BackgroundPattern' import BackgroundPattern from './components/BackgroundPattern'
import BottomDock from './components/BottomDock' import BottomDock from './components/BottomDock'
import ScrollToTop from './components/ScrollToTop' import ScrollToTop from './components/ScrollToTop'
import Home from './pages/Home'
import Articles from './pages/Articles' const Home = lazy(() => import('./pages/Home'))
import ArticleSingle from './pages/ArticleSingle' const Articles = lazy(() => import('./pages/Articles'))
import Kniepunkt from './pages/Kniepunkt' const ArticleSingle = lazy(() => import('./pages/ArticleSingle'))
import KniepunktIssue from './pages/KniepunktIssue' const Kniepunkt = lazy(() => import('./pages/Kniepunkt'))
import Podcast from './pages/Podcast' const KniepunktIssue = lazy(() => import('./pages/KniepunktIssue'))
import Speaking from './pages/Speaking' const Podcast = lazy(() => import('./pages/Podcast'))
import Consulting from './pages/Consulting' const Speaking = lazy(() => import('./pages/Speaking'))
import Resources from './pages/Resources' const Consulting = lazy(() => import('./pages/Consulting'))
import Contact from './pages/Contact' const Resources = lazy(() => import('./pages/Resources'))
import Impressum from './pages/Impressum' const Contact = lazy(() => import('./pages/Contact'))
import Datenschutz from './pages/Datenschutz' const Impressum = lazy(() => import('./pages/Impressum'))
import About from './pages/About' const Datenschutz = lazy(() => import('./pages/Datenschutz'))
import NotFound from './pages/NotFound' const About = lazy(() => import('./pages/About'))
import Confirmation from './pages/Confirmation' const NotFound = lazy(() => import('./pages/NotFound'))
const Confirmation = lazy(() => import('./pages/Confirmation'))
function PageFallback() {
return <div className="page-loading" role="status" aria-live="polite">Inhalt wird geladen </div>
}
export default function App() { export default function App() {
return ( return (
@@ -28,23 +33,25 @@ export default function App() {
<div className="app-root"> <div className="app-root">
<BackgroundPattern /> <BackgroundPattern />
<Navigation /> <Navigation />
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/artikel" element={<Articles />} /> <Route path="/" element={<Home />} />
<Route path="/artikel/:slug" element={<ArticleSingle />} /> <Route path="/artikel" element={<Articles />} />
<Route path="/kniepunkt" element={<Kniepunkt />} /> <Route path="/artikel/:slug" element={<ArticleSingle />} />
<Route path="/kniepunkt/:slug" element={<KniepunktIssue />} /> <Route path="/kniepunkt" element={<Kniepunkt />} />
<Route path="/podcast" element={<Podcast />} /> <Route path="/kniepunkt/:slug" element={<KniepunktIssue />} />
<Route path="/speaking" element={<Speaking />} /> <Route path="/podcast" element={<Podcast />} />
<Route path="/consulting" element={<Consulting />} /> <Route path="/speaking" element={<Speaking />} />
<Route path="/resources" element={<Resources />} /> <Route path="/consulting" element={<Consulting />} />
<Route path="/kontakt" element={<Contact />} /> <Route path="/resources" element={<Resources />} />
<Route path="/ueber-mich" element={<About />} /> <Route path="/kontakt" element={<Contact />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/ueber-mich" element={<About />} />
<Route path="/datenschutz" element={<Datenschutz />} /> <Route path="/impressum" element={<Impressum />} />
<Route path="/bestaetigung" element={<Confirmation />} /> <Route path="/datenschutz" element={<Datenschutz />} />
<Route path="*" element={<NotFound />} /> <Route path="/bestaetigung" element={<Confirmation />} />
</Routes> <Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
<Footer /> <Footer />
<BottomDock /> <BottomDock />
</div> </div>
@@ -0,0 +1,65 @@
import React from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BackgroundPattern from './BackgroundPattern'
import DotCloudIcon from './DotCloudIcon'
const context = {
arc: vi.fn(),
beginPath: vi.fn(),
clearRect: vi.fn(),
closePath: vi.fn(),
drawImage: vi.fn(),
ellipse: vi.fn(),
fill: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(140 * 140 * 4) })),
lineTo: vi.fn(),
moveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
scale: vi.fn(),
setTransform: vi.fn(),
}
function setReducedMotion(matches) {
vi.stubGlobal('matchMedia', vi.fn(() => ({
matches,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})))
}
describe('canvas animation lifecycle', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context)
Object.defineProperty(document, 'hidden', { configurable: true, value: false })
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 17))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('does not schedule background animation with reduced motion', () => {
setReducedMotion(true)
render(<BackgroundPattern />)
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
it('cancels a scheduled background frame on unmount', () => {
setReducedMotion(false)
const { unmount } = render(<BackgroundPattern />)
expect(requestAnimationFrame).toHaveBeenCalled()
unmount()
expect(cancelAnimationFrame).toHaveBeenCalledWith(17)
})
it('does not schedule dot animation with reduced motion', async () => {
setReducedMotion(true)
render(<DotCloudIcon shape="shield" />)
await act(async () => Promise.resolve())
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
})
@@ -15,12 +15,14 @@ export default function BackgroundPattern() {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
function resize() { function resize() {
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
canvas.width = window.innerWidth * dpr canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr canvas.height = window.innerHeight * dpr
ctx.scale(dpr, dpr) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
canvas.style.width = window.innerWidth + 'px' canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px' canvas.style.height = window.innerHeight + 'px'
initParticles() initParticles()
@@ -61,8 +63,6 @@ export default function BackgroundPattern() {
const h = window.innerHeight const h = window.innerHeight
ctx.clearRect(0, 0, w, h) ctx.clearRect(0, 0, w, h)
const style = getComputedStyle(document.documentElement)
const accentColor = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current const mouse = mouseRef.current
const repelRadius = 80 const repelRadius = 80
@@ -99,14 +99,21 @@ export default function BackgroundPattern() {
ctx.globalAlpha = 1 ctx.globalAlpha = 1
}) })
animRef.current = requestAnimationFrame(animate) if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
}
function handleVisibilityChange() {
if (document.hidden) cancelAnimationFrame(animRef.current)
else if (!reducedMotion) animate()
} }
animate() animate()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => { return () => {
cancelAnimationFrame(animRef.current) cancelAnimationFrame(animRef.current)
window.removeEventListener('resize', resize) window.removeEventListener('resize', resize)
window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, []) }, [])
@@ -76,6 +76,11 @@
transform: translateY(-8px) scale(1.15); transform: translateY(-8px) scale(1.15);
} }
.dock-item:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 3px;
}
.dock-item.active { .dock-item.active {
color: var(--accent-color); color: var(--accent-color);
background: rgba(var(--accent-color-rgb), 0.1); background: rgba(var(--accent-color-rgb), 0.1);
@@ -21,7 +21,9 @@ export default function BottomDock() {
<div className="dock-container"> <div className="dock-container">
<nav className="dock" aria-label="Schnellnavigation"> <nav className="dock" aria-label="Schnellnavigation">
{items.map((item) => { {items.map((item) => {
const isActive = location.pathname === item.path const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)
return ( return (
<div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}> <div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}>
<span className="dock-tooltip">{item.label}</span> <span className="dock-tooltip">{item.label}</span>
@@ -20,6 +20,9 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
let active = true
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr canvas.width = size * dpr
canvas.height = size * dpr canvas.height = size * dpr
@@ -27,6 +30,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
async function init() { async function init() {
const points = await getShapePoints(shape, size) const points = await getShapePoints(shape, size)
if (!active) return
particlesRef.current = points.map(p => ({ particlesRef.current = points.map(p => ({
x: p.x, y: p.y, x: p.x, y: p.y,
baseX: p.x, baseY: p.y, baseX: p.x, baseY: p.y,
@@ -34,6 +38,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
vx: 0, vy: 0, vx: 0, vy: 0,
})) }))
initializedRef.current = true initializedRef.current = true
animate()
} }
function handleMouseMove(e) { function handleMouseMove(e) {
@@ -48,16 +53,15 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
canvas.addEventListener('mousemove', handleMouseMove) canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave) canvas.addEventListener('mouseleave', handleMouseLeave)
document.addEventListener('visibilitychange', handleVisibilityChange)
function animate() { function animate() {
ctx.clearRect(0, 0, size, size) ctx.clearRect(0, 0, size, size)
if (!initializedRef.current) { if (!initializedRef.current) {
animRef.current = requestAnimationFrame(animate) if (!reducedMotion) animRef.current = requestAnimationFrame(animate)
return return
} }
const style = getComputedStyle(document.documentElement)
const color = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current const mouse = mouseRef.current
const repelRadius = 25 const repelRadius = 25
@@ -98,16 +102,22 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
ctx.fill() ctx.fill()
}) })
ctx.globalAlpha = 1 ctx.globalAlpha = 1
animRef.current = requestAnimationFrame(animate) if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
}
function handleVisibilityChange() {
if (document.hidden) cancelAnimationFrame(animRef.current)
else if (!reducedMotion) animate()
} }
init() init()
animate()
return () => { return () => {
active = false
cancelAnimationFrame(animRef.current) cancelAnimationFrame(animRef.current)
canvas.removeEventListener('mousemove', handleMouseMove) canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave) canvas.removeEventListener('mouseleave', handleMouseLeave)
document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, [shape, size]) }, [shape, size])
@@ -177,10 +187,9 @@ function sampleGermany(size) {
} }
} }
console.log('[DotCloudIcon] Germany points:', points.length)
resolve(points) resolve(points)
} }
img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) } img.onerror = () => resolve([])
img.src = '/images/germany-outline.png' img.src = '/images/germany-outline.png'
}) })
} }
@@ -26,6 +26,11 @@
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
} }
.marquee-track:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
}
.marquee-inner { .marquee-inner {
display: flex; display: flex;
gap: 60px; gap: 60px;
@@ -34,6 +39,11 @@
width: max-content; width: max-content;
} }
.marquee-section:hover .marquee-inner,
.marquee-section:focus-within .marquee-inner {
animation-play-state: paused;
}
.marquee-item { .marquee-item {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -18,10 +18,10 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>} {title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
</h2> </h2>
)} )}
<div className="marquee-track"> <div className="marquee-track" role="region" aria-label="Kunden- und Partnerlogos" tabIndex="0">
<div className="marquee-inner"> <div className="marquee-inner">
{allLogos.map((logo, i) => ( {allLogos.map((logo, i) => (
<div className="marquee-item" key={i}> <div className="marquee-item" key={i} aria-hidden={i >= logos.length}>
{logo.src ? ( {logo.src ? (
<img src={logo.src} alt={logo.name} /> <img src={logo.src} alt={logo.name} />
) : ( ) : (
@@ -0,0 +1,13 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import LogoMarquee from './LogoMarquee'
describe('LogoMarquee accessibility', () => {
it('hides loop copies from assistive technology', () => {
const { container } = render(<LogoMarquee logos={[{ name: 'Logo A' }, { name: 'Logo B' }]} />)
expect(container.querySelectorAll('.marquee-item:not([aria-hidden="true"])')).toHaveLength(2)
expect(container.querySelectorAll('.marquee-item[aria-hidden="true"]')).toHaveLength(4)
expect(container.querySelector('.marquee-track').tabIndex).toBe(0)
})
})
@@ -75,6 +75,14 @@
padding: 8px; padding: 8px;
} }
.hamburger:focus-visible,
.nav-link:focus-visible,
.nav-mobile-link:focus-visible,
.logo:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 4px;
}
.hamburger span { .hamburger span {
display: block; display: block;
width: 24px; width: 24px;
@@ -13,7 +13,15 @@ export default function Navigation() {
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll)
}, []) }, [])
// Close the mobile menu whenever navigation occurs. useEffect(() => {
function handleKeyDown(event) {
if (event.key === 'Escape') setMobileOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
function closeMenu() { function closeMenu() {
setMobileOpen(false) setMobileOpen(false)
} }
@@ -28,8 +36,10 @@ export default function Navigation() {
{ to: '/kontakt', label: 'Kontakt' }, { to: '/kontakt', label: 'Kontakt' },
] ]
const isActive = (path) => location.pathname === path || location.pathname.startsWith(`${path}/`)
return ( return (
<nav className={`navigation ${scrolled ? 'scrolled' : ''}`}> <nav className={`navigation ${scrolled ? 'scrolled' : ''}`} aria-label="Hauptnavigation">
<div className="nav-container app-container"> <div className="nav-container app-container">
<Link to="/" className="logo" onClick={closeMenu}> <Link to="/" className="logo" onClick={closeMenu}>
<span className="logo-text">Dr. André Knie</span> <span className="logo-text">Dr. André Knie</span>
@@ -40,18 +50,21 @@ export default function Navigation() {
<Link <Link
key={link.to} key={link.to}
to={link.to} to={link.to}
className={`nav-link ${location.pathname === link.to ? 'active' : ''}`} className={`nav-link ${isActive(link.to) ? 'active' : ''}`}
aria-current={isActive(link.to) ? 'page' : undefined}
> >
{link.label} {link.label}
</Link> </Link>
))} ))}
</div> </div>
{/* Mobile hamburger (only visible on small screens) */}
<button <button
className={`hamburger ${mobileOpen ? 'active' : ''}`} className={`hamburger ${mobileOpen ? 'active' : ''}`}
onClick={() => setMobileOpen(!mobileOpen)} onClick={() => setMobileOpen(open => !open)}
aria-label="Menü öffnen" aria-label={mobileOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileOpen}
aria-controls="mobile-navigation"
type="button"
> >
<span></span> <span></span>
<span></span> <span></span>
@@ -59,14 +72,14 @@ export default function Navigation() {
</button> </button>
</div> </div>
{/* Mobile dropdown */} <div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`} hidden={!mobileOpen}>
<div className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
{navLinks.map(link => ( {navLinks.map(link => (
<Link <Link
key={link.to} key={link.to}
to={link.to} to={link.to}
className={`nav-mobile-link ${location.pathname === link.to ? 'active' : ''}`} className={`nav-mobile-link ${isActive(link.to) ? 'active' : ''}`}
onClick={closeMenu} onClick={closeMenu}
aria-current={isActive(link.to) ? 'page' : undefined}
> >
{link.label} {link.label}
</Link> </Link>
@@ -0,0 +1,29 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import Navigation from './Navigation'
describe('Navigation accessibility', () => {
it('exposes menu state and closes it with Escape', () => {
render(<MemoryRouter><Navigation /></MemoryRouter>)
const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
const mobileNavigation = document.getElementById('mobile-navigation')
expect(mobileNavigation.hidden).toBe(true)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
expect(mobileNavigation.hidden).toBe(false)
fireEvent.keyDown(document, { key: 'Escape' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
expect(mobileNavigation.hidden).toBe(true)
})
it('marks detail navigation routes as current', () => {
render(<MemoryRouter initialEntries={['/artikel/beispiel']}><Navigation /></MemoryRouter>)
expect(screen.getAllByRole('link', { name: 'Artikel' }).every(link => link.getAttribute('aria-current') === 'page')).toBe(true)
})
})
@@ -40,7 +40,7 @@ export default function NewsletterSignup({ newsletter }) {
{newsletter.email_signup && ( {newsletter.email_signup && (
<form className="newsletter-email" onSubmit={handleSubmit}> <form className="newsletter-email" onSubmit={handleSubmit}>
{/* Honeypot */} {/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={(e) => setForm(p => ({ ...p, company_website: e.target.value }))} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={(e) => setForm(p => ({ ...p, company_website: e.target.value }))} />
@@ -48,7 +48,7 @@ export default function NewsletterSignup({ newsletter }) {
</div> </div>
{success ? ( {success ? (
<p className="newsletter-success">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p> <p className="newsletter-success" role="status" aria-live="polite">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p>
) : ( ) : (
<> <>
<div className="newsletter-input-row"> <div className="newsletter-input-row">
@@ -60,13 +60,15 @@ export default function NewsletterSignup({ newsletter }) {
value={form.email} value={form.email}
onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))}
aria-label="E-Mail-Adresse" aria-label="E-Mail-Adresse"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'newsletter-email-error' : undefined}
/> />
<button type="submit" className="secondary-button" disabled={loading}> <button type="submit" className="secondary-button" disabled={loading}>
{loading ? '...' : 'Abonnieren'} {loading ? '...' : 'Abonnieren'}
</button> </button>
</div> </div>
{errors.email && <p className="newsletter-error">{errors.email}</p>} {errors.email && <p id="newsletter-email-error" className="newsletter-error" role="alert">{errors.email}</p>}
{serverError && <p className="newsletter-error">{serverError}</p>} {serverError && <p className="newsletter-error" role="alert" aria-live="assertive">{serverError}</p>}
</> </>
)} )}
</form> </form>
@@ -43,7 +43,7 @@ export default function SpeakerCvRequest() {
return ( return (
<div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}> <div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}>
{success ? ( {success ? (
<p style={{ margin: 0, color: 'var(--text-primary)' }}> <p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Der Speaker CV ist auf dem Weg in dein Postfach. Danke! Der Speaker CV ist auf dem Weg in dein Postfach.
</p> </p>
) : ( ) : (
@@ -53,7 +53,7 @@ export default function SpeakerCvRequest() {
</p> </p>
{/* Honeypot */} {/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
@@ -61,24 +61,24 @@ export default function SpeakerCvRequest() {
</div> </div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label> <label htmlFor="speaker-name" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
<input name="name" value={form.name} onChange={handleChange} style={inputStyle(errors.name)} /> <input id="speaker-name" name="name" value={form.name} onChange={handleChange} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'speaker-name-error' : undefined} style={inputStyle(errors.name)} />
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>} {errors.name && <p id="speaker-name-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
</div> </div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label> <label htmlFor="speaker-email" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
<input name="email" type="email" value={form.email} onChange={handleChange} style={inputStyle(errors.email)} /> <input id="speaker-email" name="email" type="email" value={form.email} onChange={handleChange} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'speaker-email-error' : undefined} style={inputStyle(errors.email)} />
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>} {errors.email && <p id="speaker-email-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label> <label htmlFor="speaker-message" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label>
<textarea name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} style={{ ...inputStyle(errors.message), resize: 'vertical' }} /> <textarea id="speaker-message" name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} aria-invalid={Boolean(errors.message)} aria-describedby={errors.message ? 'speaker-message-error' : undefined} style={{ ...inputStyle(errors.message), resize: 'vertical' }} />
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>} {errors.message && <p id="speaker-message-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
</div> </div>
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>
<button type="submit" className="primary-button" disabled={loading}> <button type="submit" className="primary-button" disabled={loading}>
@@ -43,6 +43,13 @@
100% { transform: rotateY(-360deg); } 100% { transform: rotateY(-360deg); }
} }
@media (prefers-reduced-motion: reduce) {
.cube-container {
animation: none;
transform: rotateY(0deg);
}
}
.cube-stat-value { .cube-stat-value {
font-size: 3.5rem; font-size: 3.5rem;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -45,6 +45,12 @@
-ms-overflow-style: none; /* IE/Edge */ -ms-overflow-style: none; /* IE/Edge */
} }
.testimonial-scroll-container:focus-visible,
.testimonial-nav-button:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 4px;
}
.testimonial-scroll-container.dragging { .testimonial-scroll-container.dragging {
cursor: grabbing; cursor: grabbing;
scroll-snap-type: none; scroll-snap-type: none;
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from 'react' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css' import './TestimonialSlider.css'
@@ -12,7 +12,9 @@ const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
export default function TestimonialSlider({ testimonials = [] }) { export default function TestimonialSlider({ testimonials = [] }) {
const scrollerRef = useRef(null) const scrollerRef = useRef(null)
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 }) const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const recenterTimerRef = useRef(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const canLoop = testimonials.length > 1 const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop const visibleTestimonials = canLoop
? Array.from({ length: LOOP_SETS }, () => testimonials).flat() ? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
@@ -46,10 +48,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
const centerCard = (cardIndex, behavior = 'auto') => { const centerCard = (cardIndex, behavior = 'auto') => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
scroller.scrollTo({ scroller.scrollTo({
left: getCenteredScrollLeft(cardIndex), left: getCenteredScrollLeft(cardIndex),
behavior, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : behavior,
}) })
} }
@@ -67,6 +71,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
return () => window.cancelAnimationFrame(frameId) return () => window.cancelAnimationFrame(frameId)
}, [canLoop, testimonials.length]) }, [canLoop, testimonials.length])
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
const scrollByCard = (direction) => { const scrollByCard = (direction) => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
@@ -74,7 +80,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
const targetIndex = getClosestCardIndex() + direction const targetIndex = getClosestCardIndex() + direction
centerCard(targetIndex, 'smooth') centerCard(targetIndex, 'smooth')
window.setTimeout(recenterIfNeeded, 450) window.clearTimeout(recenterTimerRef.current)
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
} }
const recenterIfNeeded = () => { const recenterIfNeeded = () => {
@@ -82,11 +89,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
if (!canLoop || !scroller) return if (!canLoop || !scroller) return
const currentIndex = getClosestCardIndex() const currentIndex = getClosestCardIndex()
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
const lowBoundary = testimonials.length const lowBoundary = testimonials.length
const highBoundary = testimonials.length * (LOOP_SETS - 1) const highBoundary = testimonials.length * (LOOP_SETS - 1)
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex) centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
} }
@@ -123,10 +131,21 @@ export default function TestimonialSlider({ testimonials = [] }) {
} }
} }
const handleKeyDown = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollByCard(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollByCard(1)
}
}
if (testimonials.length === 0) return null if (testimonials.length === 0) return null
return ( return (
<section className="testimonials-section app-container"> <section className="testimonials-section app-container" aria-labelledby="testimonials-heading">
<h2 id="testimonials-heading" className="sr-only">Testimonials</h2>
<div className="testimonial-slider-controls" aria-label="Testimonials wechseln"> <div className="testimonial-slider-controls" aria-label="Testimonials wechseln">
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial"> <button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial">
<ChevronLeft size={22} aria-hidden="true" /> <ChevronLeft size={22} aria-hidden="true" />
@@ -135,9 +154,17 @@ export default function TestimonialSlider({ testimonials = [] }) {
<ChevronRight size={22} aria-hidden="true" /> <ChevronRight size={22} aria-hidden="true" />
</button> </button>
</div> </div>
<p className="sr-only" role="status" aria-live="polite">
Testimonial {activeIndex + 1} von {testimonials.length}
</p>
<div <div
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`} className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
ref={scrollerRef} ref={scrollerRef}
role="region"
aria-label={`Testimonial ${testimonials.length > 1 ? 'Karussell' : ''}`}
aria-roledescription="Karussell"
tabIndex="0"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={stopDragging} onPointerUp={stopDragging}
@@ -147,7 +174,7 @@ export default function TestimonialSlider({ testimonials = [] }) {
}} }}
> >
{visibleTestimonials.map((current, i) => ( {visibleTestimonials.map((current, i) => (
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`}> <div className="testimonial-card glass-panel" key={`${current.name}-${i}`} aria-hidden={canLoop && Math.floor(i / testimonials.length) !== MIDDLE_SET}>
<div className="testimonial-quote"> <div className="testimonial-quote">
<span className="quote-mark">"</span> <span className="quote-mark">"</span>
<p>{current.quote}</p> <p>{current.quote}</p>
@@ -0,0 +1,17 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import TestimonialSlider from './TestimonialSlider'
describe('TestimonialSlider accessibility', () => {
it('exposes only one logical testimonial set', () => {
const testimonials = [
{ name: 'Person A', quote: 'Zitat A', role: 'Rolle A' },
{ name: 'Person B', quote: 'Zitat B', role: 'Rolle B' },
{ name: 'Person C', quote: 'Zitat C', role: 'Rolle C' },
]
const { container } = render(<TestimonialSlider testimonials={testimonials} />)
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3')
})
})
+34 -2
View File
@@ -1,4 +1,8 @@
import { useEffect, useState } from 'react'
import content from 'virtual:content' import content from 'virtual:content'
import detailLoaders from 'virtual:content-detail-loaders'
const detailCache = new Map()
/** /**
* Get all content of a given type, sorted by date descending. * Get all content of a given type, sorted by date descending.
@@ -50,8 +54,36 @@ export function useContent(type, options = {}) {
* Get a single content item by slug. * Get a single content item by slug.
*/ */
export function useContentItem(type, slug) { export function useContentItem(type, slug) {
const items = content[type] || [] const metadata = (content[type] || []).find(item => item.slug === slug) || null
return items.find(item => item.slug === slug) || null const cacheKey = `${type}:${slug}`
const [detailState, setDetailState] = useState(() => ({
key: cacheKey,
item: detailCache.get(cacheKey) || metadata,
}))
useEffect(() => {
let active = true
if (!metadata || detailCache.has(cacheKey)) return () => { active = false }
const loadDetail = detailLoaders[type]?.[slug]
const detailPromise = loadDetail
? loadDetail()
: Promise.reject(new Error(`No detail loader for ${cacheKey}`))
detailPromise
.then(module => {
const detail = module.default
detailCache.set(cacheKey, detail)
if (active) setDetailState({ key: cacheKey, item: detail })
})
.catch(() => {
if (active) setDetailState({ key: cacheKey, item: { ...metadata, detailError: true } })
})
return () => { active = false }
}, [cacheKey, metadata, slug, type])
return detailState.key === cacheKey ? detailState.item : metadata
} }
/** /**
+38
View File
@@ -163,6 +163,34 @@ img {
color: var(--accent-color); color: var(--accent-color);
} }
.page-loading {
min-height: 50vh;
display: grid;
place-items: center;
padding: 120px 24px 80px;
color: var(--text-secondary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 3px;
}
/* Section layout */ /* Section layout */
.section-header { .section-header {
text-align: center; text-align: center;
@@ -204,3 +232,13 @@ img {
0% { opacity: 0; } 0% { opacity: 0; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}
@@ -1,13 +1,12 @@
import React from 'react' import React from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useContent } from '../hooks/useContent' import { useContentItem } from '../hooks/useContent'
import SEOHead from '../components/SEOHead' import SEOHead from '../components/SEOHead'
import { decodeHtml } from '../utils/decodeHtml' import { decodeHtml } from '../utils/decodeHtml'
export default function ArticleSingle() { export default function ArticleSingle() {
const { slug } = useParams() const { slug } = useParams()
const { items: posts } = useContent('posts', { pageSize: 1000 }) const post = useContentItem('posts', slug)
const post = posts.find((p) => p.slug === slug)
if (!post) { if (!post) {
return ( return (
@@ -24,6 +23,18 @@ export default function ArticleSingle() {
) )
} }
if (!post.body) {
const loadFailed = post.detailError
return (
<main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
<SEOHead title={post.title} description={post.excerpt || post.title} url={`/artikel/${post.slug}`} type="article" />
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Der Artikel konnte nicht geladen werden.' : 'Artikel wird geladen …'}
</p>
</main>
)
}
const formattedDate = post.date const formattedDate = post.date
? new Date(post.date).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' }) ? new Date(post.date).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' })
: '' : ''
@@ -15,7 +15,7 @@ export default function Articles() {
return ( return (
(post.title && post.title.toLowerCase().includes(query)) || (post.title && post.title.toLowerCase().includes(query)) ||
(post.excerpt && post.excerpt.toLowerCase().includes(query)) || (post.excerpt && post.excerpt.toLowerCase().includes(query)) ||
(post.body && post.body.toLowerCase().includes(query)) (post.searchText && post.searchText.toLowerCase().includes(query))
) )
}) })
@@ -45,6 +45,7 @@ export default function Articles() {
<input <input
type="text" type="text"
placeholder="Artikel durchsuchen..." placeholder="Artikel durchsuchen..."
aria-label="Artikel durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -84,7 +85,7 @@ export default function Articles() {
)} )}
<h2 className="article-title">{decodeHtml(post.title)}</h2> <h2 className="article-title">{decodeHtml(post.title)}</h2>
<p className="article-excerpt"> <p className="article-excerpt">
{decodeHtml(post.excerpt || (post.body ? post.body.replace(/<[^>]+>/g, '').substring(0, 150) + '...' : ''))} {decodeHtml(post.excerpt || (post.searchText ? post.searchText.substring(0, 150) + '...' : ''))}
</p> </p>
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
<a href={`/artikel/${post.slug}`} className="article-link"> <a href={`/artikel/${post.slug}`} className="article-link">
+14 -12
View File
@@ -21,7 +21,9 @@ export default function Contact() {
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Kontakt" description="Nimm Kontakt zu Dr. André Knie auf." url="/kontakt" /> <SEOHead title="Kontakt" description="Nimm Kontakt zu Dr. André Knie auf." url="/kontakt" />
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}> <div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1> <div role="status" aria-live="polite">
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1>
</div>
<p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p> <p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p>
</div> </div>
</main> </main>
@@ -39,7 +41,7 @@ export default function Contact() {
<form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '40px' }}> <form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '40px' }}>
{/* Honeypot — hidden from real users, only bots fill this. */} {/* Honeypot — hidden from real users, only bots fill this. */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
@@ -47,25 +49,25 @@ export default function Contact() {
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label> <label htmlFor="contact-name" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
<input name="name" value={form.name} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.name ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} /> <input id="contact-name" name="name" value={form.name} onChange={handleChange} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'contact-name-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.name ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>} {errors.name && <p id="contact-name-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label> <label htmlFor="contact-email" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
<input name="email" type="email" value={form.email} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.email ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} /> <input id="contact-email" name="email" type="email" value={form.email} onChange={handleChange} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'contact-email-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.email ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>} {errors.email && <p id="contact-email-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
</div> </div>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Nachricht</label> <label htmlFor="contact-message" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Nachricht</label>
<textarea name="message" value={form.message} onChange={handleChange} rows={6} maxLength={2000} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.message ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem', resize: 'vertical' }} /> <textarea id="contact-message" name="message" value={form.message} onChange={handleChange} rows={6} maxLength={2000} aria-invalid={Boolean(errors.message)} aria-describedby={errors.message ? 'contact-message-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.message ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem', resize: 'vertical' }} />
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>} {errors.message && <p id="contact-message-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
<p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', margin: '4px 0 0' }}>{form.message.length}/2000</p> <p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', margin: '4px 0 0' }}>{form.message.length}/2000</p>
</div> </div>
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
<button type="submit" className="primary-button" disabled={loading} style={{ width: '100%' }}> <button type="submit" className="primary-button" disabled={loading} style={{ width: '100%' }}>
{loading ? 'Wird gesendet...' : 'Nachricht senden'} {loading ? 'Wird gesendet...' : 'Nachricht senden'}
+9 -1
View File
@@ -26,7 +26,15 @@ export default function Home() {
{/* Hero */} {/* Hero */}
<section className="hero"> <section className="hero">
<div className="app-container hero-content"> <div className="app-container hero-content">
<img src="/images/andre-knie.webp" alt="Dr. André Knie" className="hero-photo" /> <img
src="/images/andre-knie.webp?v=2"
alt="Dr. André Knie"
className="hero-photo"
width="280"
height="280"
decoding="async"
fetchPriority="high"
/>
<h1> <h1>
{profile?.name || 'Dr. André Knie'} {profile?.name || 'Dr. André Knie'}
</h1> </h1>
@@ -16,7 +16,7 @@ export default function Kniepunkt() {
return ( return (
(item.title && item.title.toLowerCase().includes(query)) || (item.title && item.title.toLowerCase().includes(query)) ||
(item.summary && item.summary.toLowerCase().includes(query)) || (item.summary && item.summary.toLowerCase().includes(query)) ||
(item.body && item.body.toLowerCase().includes(query)) (item.searchText && item.searchText.toLowerCase().includes(query))
) )
}) })
@@ -36,6 +36,7 @@ export default function Kniepunkt() {
<input <input
type="text" type="text"
placeholder="Kniepunkte durchsuchen..." placeholder="Kniepunkte durchsuchen..."
aria-label="Kniepunkte durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -21,6 +21,20 @@ export default function KniepunktIssue() {
) )
} }
if (!issue.body) {
const loadFailed = issue.detailError
return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
<div className="app-container" style={{ textAlign: 'center' }}>
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Die Kniepunkt-Ausgabe konnte nicht geladen werden.' : 'Kniepunkt-Ausgabe wird geladen …'}
</p>
</div>
</main>
)
}
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} /> <SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
@@ -31,7 +31,7 @@ function ResourceRequestForm({ resource, onCancel }) {
return ( return (
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}> <div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
{success ? ( {success ? (
<p style={{ margin: 0, color: 'var(--text-primary)' }}> <p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Wir prüfen die Anfrage und melden uns bei dir. Danke! Wir prüfen die Anfrage und melden uns bei dir.
</p> </p>
) : ( ) : (
@@ -41,7 +41,7 @@ function ResourceRequestForm({ resource, onCancel }) {
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich. Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
</p> </p>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label> <label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} /> <input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
</div> </div>
@@ -49,18 +49,18 @@ function ResourceRequestForm({ resource, onCancel }) {
<input type="hidden" name="resource_id" value={resourceId} /> <input type="hidden" name="resource_id" value={resourceId} />
<label htmlFor="resource-name">Name</label> <label htmlFor="resource-name">Name</label>
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} /> <input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'resource-name-error' : undefined} />
{errors.name && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>} {errors.name && <p id="resource-name-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
<label htmlFor="resource-email">E-Mail</label> <label htmlFor="resource-email">E-Mail</label>
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} /> <input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'resource-email-error' : undefined} />
{errors.email && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>} {errors.email && <p id="resource-email-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
<label htmlFor="resource-company">Unternehmen</label> <label htmlFor="resource-company">Unternehmen</label>
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} /> <input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} aria-describedby={errors.company ? 'resource-company-error' : undefined} />
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>} {errors.company && <p id="resource-company-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
{serverError && <p role="alert" style={{ color: '#ff4444' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444' }}>{serverError}</p>}
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>} {errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>