29 changed files with 555 additions and 131 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 -- {} +
@@ -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
} }
} }
} }
@@ -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'}
@@ -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' }}>