feat(privat/CV): sync to latest upstream (cv-upstream/main, 30f9608b)

This commit is contained in:
2026-07-07 15:20:55 +02:00
parent 8ac664d33d
commit a4efabbc60
417 changed files with 48564 additions and 712 deletions
@@ -0,0 +1,9 @@
.bg-particle-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -10;
pointer-events: none;
}
@@ -0,0 +1,114 @@
import React, { useRef, useEffect } from 'react'
import './BackgroundPattern.css'
/**
* Full-page particle background — subtle floating dots that react to mouse.
* Replaces the old SVG pattern drift animation.
*/
export default function BackgroundPattern() {
const canvasRef = useRef(null)
const animRef = useRef(null)
const mouseRef = useRef({ x: -1000, y: -1000 })
const particlesRef = useRef([])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
function resize() {
const dpr = window.devicePixelRatio || 1
canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr
ctx.scale(dpr, dpr)
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
initParticles()
}
function initParticles() {
const w = window.innerWidth
const h = window.innerHeight
const count = Math.floor((w * h) / 4000) // ~500 on 1920x1080
particlesRef.current = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
baseX: 0,
baseY: 0,
vx: 0,
vy: 0,
radius: 0.8 + Math.random() * 1.2,
drift: Math.random() * Math.PI * 2,
driftSpeed: 0.002 + Math.random() * 0.003,
}))
particlesRef.current.forEach(p => {
p.baseX = p.x
p.baseY = p.y
})
}
function handleMouseMove(e) {
mouseRef.current.x = e.clientX
mouseRef.current.y = e.clientY
}
resize()
window.addEventListener('resize', resize)
window.addEventListener('mousemove', handleMouseMove)
function animate() {
const w = window.innerWidth
const h = window.innerHeight
ctx.clearRect(0, 0, w, h)
const style = getComputedStyle(document.documentElement)
const accentColor = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current
const repelRadius = 80
particlesRef.current.forEach(p => {
// Subtle autonomous drift
p.drift += p.driftSpeed
const driftX = Math.sin(p.drift) * 0.3
const driftY = Math.cos(p.drift * 0.7) * 0.3
// Mouse repulsion
const dx = p.x - mouse.x
const dy = p.y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < repelRadius && dist > 0) {
const force = (repelRadius - dist) / repelRadius
p.vx += (dx / dist) * force * 1.5
p.vy += (dy / dist) * force * 1.5
}
// Spring back
p.vx += (p.baseX - p.x) * 0.02 + driftX * 0.1
p.vy += (p.baseY - p.y) * 0.02 + driftY * 0.1
p.vx *= 0.92
p.vy *= 0.92
p.x += p.vx
p.y += p.vy
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
ctx.fillStyle = accentColor
ctx.globalAlpha = 0.25
ctx.fill()
ctx.globalAlpha = 1
})
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => {
cancelAnimationFrame(animRef.current)
window.removeEventListener('resize', resize)
window.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return <canvas ref={canvasRef} className="bg-particle-canvas" />
}
@@ -0,0 +1,119 @@
.dock-container {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
justify-content: center;
}
.dock {
position: relative;
overflow: hidden;
padding: 8px 12px;
border-radius: var(--radius-lg);
display: flex;
gap: 12px;
align-items: center;
box-shadow: var(--glass-shadow), 0 0 20px rgba(var(--accent-color-rgb), 0.05);
z-index: 1;
}
.dock::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 250%;
height: 250%;
background: conic-gradient(transparent, transparent, transparent, rgba(var(--accent-color-rgb), 1));
transform: translate(-50%, -50%);
animation: rotateLight 4s linear infinite;
z-index: -2;
}
.dock::after {
content: '';
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
background: rgba(20, 20, 20, 0.9);
backdrop-filter: blur(20px);
border-radius: calc(var(--radius-lg) - 1px);
z-index: -1;
}
@keyframes rotateLight {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.dock-item-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
z-index: 2;
}
.dock-item {
width: 44px;
height: 44px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--radius-md);
color: var(--text-secondary);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.dock-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-8px) scale(1.15);
}
.dock-item.active {
color: var(--accent-color);
background: rgba(var(--accent-color-rgb), 0.1);
}
.dock-item.active::after {
content: '';
position: absolute;
bottom: -4px;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--accent-color);
box-shadow: 0 0 10px var(--accent-color);
}
.dock-tooltip {
position: absolute;
top: -40px;
background: rgba(10, 10, 10, 0.9);
color: #fff;
padding: 5px 10px;
border-radius: var(--radius-sm);
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: all var(--transition-fast);
border: 1px solid var(--glass-border);
}
.dock-item-wrapper:hover .dock-tooltip {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 600px) {
.dock { gap: 6px; padding: 6px 8px; }
.dock-item { width: 38px; height: 38px; }
}
@@ -0,0 +1,36 @@
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Home, Newspaper, Mic, MessageSquare, Briefcase, User, Mail } from 'lucide-react'
import './BottomDock.css'
export default function BottomDock() {
const location = useLocation()
const items = [
{ label: 'Home', path: '/', icon: <Home size={20} /> },
{ label: 'Kniepunkt', path: '/kniepunkt', icon: <Newspaper size={20} /> },
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} /> },
{ label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> },
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> },
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> },
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
]
return (
<div className="dock-container">
<nav className="dock">
{items.map((item) => {
const isActive = location.pathname === item.path
return (
<div className="dock-item-wrapper" key={item.path}>
<span className="dock-tooltip">{item.label}</span>
<Link to={item.path} className={`dock-item ${isActive ? 'active' : ''}`}>
{item.icon}
</Link>
</div>
)
})}
</nav>
</div>
)
}
@@ -0,0 +1,325 @@
import React, { useRef, useEffect } from 'react'
/**
* Particle icon sampled from an image.
* - Loads an image (germany map, shield+gear, wrench)
* - Samples dark pixels → places dots
* - Dots are STATIC until mouse hovers, then they scatter and spring back
*
* For germany: uses /images/germany-outline.png
* For shield/tools: draws shape on offscreen canvas then samples
*/
export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
const canvasRef = useRef(null)
const animRef = useRef(null)
const particlesRef = useRef([])
const mouseRef = useRef({ x: -1000, y: -1000 })
const initializedRef = useRef(false)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr
canvas.height = size * dpr
ctx.scale(dpr, dpr)
async function init() {
const points = await getShapePoints(shape, size)
particlesRef.current = points.map(p => ({
x: p.x, y: p.y,
baseX: p.x, baseY: p.y,
r: p.r || (0.8 + Math.random() * 1.0),
vx: 0, vy: 0,
}))
initializedRef.current = true
}
function handleMouseMove(e) {
const rect = canvas.getBoundingClientRect()
mouseRef.current.x = e.clientX - rect.left
mouseRef.current.y = e.clientY - rect.top
}
function handleMouseLeave() {
mouseRef.current.x = -1000
mouseRef.current.y = -1000
}
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave)
function animate() {
ctx.clearRect(0, 0, size, size)
if (!initializedRef.current) {
animRef.current = requestAnimationFrame(animate)
return
}
const style = getComputedStyle(document.documentElement)
const color = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current
const repelRadius = 25
particlesRef.current.forEach(p => {
// Mouse repulsion
const dx = p.x - mouse.x
const dy = p.y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < repelRadius && dist > 0) {
const force = (repelRadius - dist) / repelRadius
p.vx += (dx / dist) * force * 3
p.vy += (dy / dist) * force * 3
}
// Spring back to base (only if displaced)
const dispX = p.baseX - p.x
const dispY = p.baseY - p.y
if (Math.abs(dispX) > 0.1 || Math.abs(dispY) > 0.1) {
p.vx += dispX * 0.08
p.vy += dispY * 0.08
}
// Damping
p.vx *= 0.85
p.vy *= 0.85
// Apply velocity only if significant
if (Math.abs(p.vx) > 0.01 || Math.abs(p.vy) > 0.01) {
p.x += p.vx
p.y += p.vy
}
// Draw
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.globalAlpha = 0.7
ctx.fill()
})
ctx.globalAlpha = 1
animRef.current = requestAnimationFrame(animate)
}
init()
animate()
return () => {
cancelAnimationFrame(animRef.current)
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave)
}
}, [shape, size])
return (
<canvas
ref={canvasRef}
style={{ width: size, height: size, display: 'block', margin: '0 auto 16px', cursor: 'default' }}
/>
)
}
async function getShapePoints(shape, size) {
if (shape === 'germany') {
return sampleGermany(size)
} else if (shape === 'shield') {
return sampleShieldDrawn(size)
} else if (shape === 'person') {
return samplePersonDrawn(size)
} else {
return sampleToolsRightHalf(size)
}
}
/**
* Germany: Load image, find all non-white/non-transparent pixels
* Border = very dark pixels, Interior = medium pixels
*/
function sampleGermany(size) {
return new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const off = document.createElement('canvas')
off.width = size
off.height = size
const ctx = off.getContext('2d')
const aspect = img.width / img.height
let dw, dh, dx, dy
if (aspect > 1) { dw = size; dh = size / aspect; dx = 0; dy = (size - dh) / 2 }
else { dh = size; dw = size * aspect; dx = (size - dw) / 2; dy = 0 }
ctx.drawImage(img, dx, dy, dw, dh)
const imageData = ctx.getImageData(0, 0, size, size)
const points = []
// Find non-background pixels: image is GREY/WHITE on TRANSPARENT background
// So we look for pixels that have alpha > 50 (= part of the map, not background)
for (let y = 1; y < size - 1; y += 1) {
for (let x = 1; x < size - 1; x += 1) {
const i = (y * size + x) * 4
const a = imageData.data[i+3]
// The map is grey/white shapes on transparent — any pixel with alpha IS the map
if (a < 30) continue // skip transparent background
// Denser for higher alpha (more opaque = more solid part of map)
if (a > 180) {
// Very opaque = solid fill or border
if (x % 2 === 0 && y % 2 === 0 && Math.random() < 0.85) {
points.push({ x: x + (Math.random()-0.5), y: y + (Math.random()-0.5), r: 0.6 + Math.random()*0.4 })
}
} else if (a > 80) {
// Semi-transparent = lighter fill areas
if (x % 3 === 0 && y % 3 === 0 && Math.random() < 0.5) {
points.push({ x: x + (Math.random()-0.5)*1.5, y: y + (Math.random()-0.5)*1.5, r: 0.5 + Math.random()*0.3 })
}
}
}
}
console.log('[DotCloudIcon] Germany points:', points.length)
resolve(points)
}
img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) }
img.src = '/images/germany-outline.png'
})
}
/**
* Shield with gear — drawn programmatically (the old version you liked)
*/
function sampleShieldDrawn(size) {
const off = document.createElement('canvas')
off.width = size
off.height = size
const ctx = off.getContext('2d')
const s = size / 140
ctx.fillStyle = '#000'
const cx = 70 * s
// Shield
ctx.beginPath()
ctx.moveTo(cx, 12*s)
ctx.lineTo(105*s, 25*s)
ctx.lineTo(105*s, 70*s)
ctx.quadraticCurveTo(105*s, 105*s, cx, 125*s)
ctx.quadraticCurveTo(35*s, 105*s, 35*s, 70*s)
ctx.lineTo(35*s, 25*s)
ctx.closePath()
ctx.fill()
// Gear cutout (white)
ctx.fillStyle = '#fff'
const gcy = 65*s, gr = 20*s
ctx.beginPath()
for (let i = 0; i <= 360; i += 2) {
const a = (i * Math.PI) / 180
const tooth = Math.sin(i * Math.PI / 22.5) > 0 ? 5*s : 0
const r = gr + tooth
if (i === 0) ctx.moveTo(cx + Math.cos(a)*r, gcy + Math.sin(a)*r)
else ctx.lineTo(cx + Math.cos(a)*r, gcy + Math.sin(a)*r)
}
ctx.closePath()
ctx.fill()
// Gear center (black)
ctx.fillStyle = '#000'
ctx.beginPath()
ctx.arc(cx, gcy, 8*s, 0, Math.PI*2)
ctx.fill()
const imageData = ctx.getImageData(0, 0, size, size)
const points = []
const step = 2
for (let y = 0; y < size; y += step) {
for (let x = 0; x < size; x += step) {
const i = (y * size + x) * 4
if (imageData.data[i+3] > 128 && imageData.data[i] < 128) {
points.push({ x: x + (Math.random()-0.5)*2, y: y + (Math.random()-0.5)*2, r: 0.7 + Math.random()*0.8 })
}
}
}
return points
}
/**
* Person icon — drawn: head circle + body/shoulders
*/
function samplePersonDrawn(size) {
const off = document.createElement('canvas')
off.width = size
off.height = size
const ctx = off.getContext('2d')
const s = size / 140
ctx.fillStyle = '#000'
// Head
ctx.beginPath()
ctx.arc(70*s, 35*s, 18*s, 0, Math.PI*2)
ctx.fill()
// Body (torso)
ctx.beginPath()
ctx.moveTo(45*s, 65*s)
ctx.quadraticCurveTo(70*s, 55*s, 95*s, 65*s)
ctx.lineTo(90*s, 120*s)
ctx.lineTo(50*s, 120*s)
ctx.closePath()
ctx.fill()
// Arms
ctx.beginPath()
ctx.ellipse(38*s, 85*s, 8*s, 22*s, 0.2, 0, Math.PI*2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(102*s, 85*s, 8*s, 22*s, -0.2, 0, Math.PI*2)
ctx.fill()
const imageData = ctx.getImageData(0, 0, size, size)
const points = []
const step = 2
for (let y = 0; y < size; y += step) {
for (let x = 0; x < size; x += step) {
const i = (y * size + x) * 4
if (imageData.data[i+3] > 128 && imageData.data[i] < 128) {
points.push({ x: x + (Math.random()-0.5)*2, y: y + (Math.random()-0.5)*2, r: 0.7 + Math.random()*0.8 })
}
}
}
return points
}
/**
* Tools: Load image, crop to right half, scale 2x
*/
function sampleToolsRightHalf(size) {
return new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const off = document.createElement('canvas')
off.width = size
off.height = size
const ctx = off.getContext('2d')
// Draw only the right half of the image, scaled 2x to fill
const srcX = img.width / 2
const srcW = img.width / 2
const srcH = img.height
ctx.drawImage(img, srcX, 0, srcW, srcH, 0, 0, size, size)
const imageData = ctx.getImageData(0, 0, size, size)
const points = []
const step = 2
for (let y = step; y < size - step; y += step) {
for (let x = step; x < size - step; x += step) {
const i = (y * size + x) * 4
const r = imageData.data[i], g = imageData.data[i+1], b = imageData.data[i+2], a = imageData.data[i+3]
if (a > 80 && (r + g + b) < 500) {
points.push({ x: x + (Math.random()-0.5)*2, y: y + (Math.random()-0.5)*2, r: 0.7 + Math.random()*0.8 })
}
}
}
resolve(points)
}
img.onerror = () => resolve([])
img.src = '/images/tools.jpg'
})
}
@@ -0,0 +1,81 @@
.footer-section {
padding: 80px 0 120px 0;
}
.cta-container {
margin-bottom: 80px;
}
.cta-content {
text-align: center;
padding: 80px 40px;
background: linear-gradient(145deg, rgba(25, 25, 25, 0.8), rgba(10, 10, 10, 0.9));
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.cta-content h2 {
font-size: 2.5rem;
margin-bottom: 20px;
}
.cta-content p {
color: var(--text-secondary);
font-size: 1.2rem;
max-width: 600px;
margin: 0 auto 40px auto;
}
.footer-bottom {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-top: 40px;
}
.footer-brand {
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-name {
font-weight: var(--font-weight-bold);
font-size: 1.1rem;
}
.footer-dhive {
color: var(--text-secondary);
font-size: 0.85rem;
}
.footer-dhive:hover {
color: var(--accent-color);
}
.footer-links {
display: flex;
gap: 24px;
}
.footer-links a {
color: var(--text-secondary);
}
.footer-links a:hover {
color: #fff;
}
.footer-copyright {
color: var(--text-secondary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.footer-bottom {
flex-direction: column;
gap: 20px;
text-align: center;
}
}
@@ -0,0 +1,41 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { useMeta } from '../hooks/useContent'
import NewsletterSignup from './NewsletterSignup'
import './Footer.css'
export default function Footer() {
const { newsletter } = useMeta()
return (
<footer className="footer-section">
<div className="app-container">
<NewsletterSignup newsletter={newsletter} />
</div>
<div className="cta-container app-container">
<div className="cta-content glass-panel">
<h2>Lass uns sprechen.</h2>
<p>KI-Strategie, Vortrag, Workshop oder einfach ein gutes Gespräch über Technologie und Menschen.</p>
<Link to="/kontakt" className="primary-button">Kontakt aufnehmen</Link>
</div>
</div>
<div className="footer-bottom app-container">
<div className="footer-brand">
<span className="footer-name">Dr. André Knie</span>
<a href="https://d-hive.de" target="_blank" rel="noopener noreferrer" className="footer-dhive">
Founder & GF, Data Hive Cassel
</a>
</div>
<div className="footer-links">
<Link to="/impressum">Impressum</Link>
<Link to="/datenschutz">Datenschutz</Link>
</div>
<div className="footer-copyright">
&copy; {new Date().getFullYear()} Dr. André Knie
</div>
</div>
</footer>
)
}
@@ -0,0 +1,98 @@
.marquee-section {
padding: 20px 0;
overflow: hidden;
margin-top: 60px;
}
.marquee-section + .marquee-section {
margin-top: 0;
padding-top: 10px;
padding-bottom: 60px;
}
.marquee-section:first-of-type {
padding-top: 40px;
}
.marquee-title {
text-align: center;
margin-bottom: 32px;
}
.marquee-track {
width: 100%;
overflow: hidden;
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-inner {
display: flex;
gap: 60px;
align-items: center;
animation: marqueeScroll 30s linear infinite;
width: max-content;
}
.marquee-item {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.marquee-dark .marquee-item {
filter: none;
}
.marquee-light .marquee-item {
filter: none;
}
.marquee-item:hover {
opacity: 1;
}
.marquee-item img {
height: 40px;
max-height: 40px;
width: auto;
max-width: 200px;
object-fit: contain;
}
/* Light variant background */
.marquee-light {
background: rgba(240, 240, 240, 0.95);
border-top: 1px solid rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.marquee-light .marquee-title {
color: #555;
}
.marquee-light .marquee-text-logo {
color: #333;
}
.marquee-item img {
height: 36px;
width: auto;
object-fit: contain;
}
.marquee-text-logo {
font-size: 1.1rem;
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
white-space: nowrap;
}
@keyframes marqueeScroll {
0% { transform: translateX(0); }
100% { transform: translateX(-33.33%); }
}
@@ -0,0 +1,36 @@
import React from 'react'
import './LogoMarquee.css'
/**
* Scrolling logo marquee showing clients/partners/media.
* Supports variant="dark" (default) and variant="light" for visibility of different logo colors.
*/
export default function LogoMarquee({ logos = [], title = '', titleAccent = '', variant = 'dark' }) {
if (logos.length === 0) return null
// Duplicate logos for seamless loop
const allLogos = [...logos, ...logos, ...logos]
return (
<section className={`marquee-section marquee-${variant}`}>
{(title || titleAccent) && (
<h2 className="marquee-title section-title">
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
</h2>
)}
<div className="marquee-track">
<div className="marquee-inner">
{allLogos.map((logo, i) => (
<div className="marquee-item" key={i}>
{logo.src ? (
<img src={logo.src} alt={logo.name} />
) : (
<span className="marquee-text-logo">{logo.name}</span>
)}
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,132 @@
.navigation {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
transition: all var(--transition-fast);
padding: 16px 0;
background: rgba(10, 10, 10, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid transparent;
}
.navigation.scrolled {
padding: 12px 0;
border-bottom: 1px solid var(--glass-border);
background: rgba(10, 10, 10, 0.85);
}
.nav-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo-text {
font-size: 1.2rem;
font-weight: var(--font-weight-bold);
letter-spacing: -0.02em;
}
/* Desktop nav links */
.nav-links {
display: flex;
gap: 32px;
align-items: center;
}
.nav-link {
font-size: 0.9rem;
font-weight: var(--font-weight-normal);
color: var(--text-secondary);
transition: color var(--transition-fast);
position: relative;
}
.nav-link:hover {
color: var(--text-primary);
}
.nav-link.active {
color: var(--accent-color);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: -6px;
left: 0;
width: 100%;
height: 2px;
background: var(--accent-color);
border-radius: 1px;
}
/* Hamburger — hidden on desktop, shown on mobile */
.hamburger {
display: none;
flex-direction: column;
gap: 5px;
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
}
.hamburger span {
display: block;
width: 24px;
height: 2px;
background-color: var(--text-primary);
transition: all var(--transition-fast);
}
.hamburger.active span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger.active span:nth-child(2) { opacity: 0; }
.hamburger.active span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* Mobile dropdown */
.nav-mobile {
display: none;
flex-direction: column;
background: rgba(10, 10, 10, 0.95);
backdrop-filter: blur(15px);
border-bottom: 1px solid var(--glass-border);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
}
.nav-mobile.open {
max-height: 400px;
padding: 16px 0;
}
.nav-mobile-link {
display: block;
padding: 12px 24px;
color: var(--text-secondary);
font-size: 1rem;
transition: color var(--transition-fast), background var(--transition-fast);
}
.nav-mobile-link:hover,
.nav-mobile-link.active {
color: var(--accent-color);
background: rgba(var(--accent-color-rgb), 0.05);
}
/* Responsive: show hamburger + mobile menu on small screens */
@media (max-width: 900px) {
.nav-links {
display: none;
}
.hamburger {
display: flex;
}
.nav-mobile {
display: flex;
}
}
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import './Navigation.css'
export default function Navigation() {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 50)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close the mobile menu whenever navigation occurs.
function closeMenu() {
setMobileOpen(false)
}
const navLinks = [
{ to: '/kniepunkt', label: 'Kniepunkt' },
{ to: '/podcast', label: 'Podcast' },
{ to: '/speaking', label: 'Speaking' },
{ to: '/consulting', label: 'Beratung' },
{ to: '/ueber-mich', label: 'Über mich' },
{ to: '/kontakt', label: 'Kontakt' },
]
return (
<nav className={`navigation ${scrolled ? 'scrolled' : ''}`}>
<div className="nav-container app-container">
<Link to="/" className="logo" onClick={closeMenu}>
<span className="logo-text">Dr. André Knie</span>
</Link>
<div className="nav-links">
{navLinks.map(link => (
<Link
key={link.to}
to={link.to}
className={`nav-link ${location.pathname === link.to ? 'active' : ''}`}
>
{link.label}
</Link>
))}
</div>
{/* Mobile hamburger (only visible on small screens) */}
<button
className={`hamburger ${mobileOpen ? 'active' : ''}`}
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Menü öffnen"
>
<span></span>
<span></span>
<span></span>
</button>
</div>
{/* Mobile dropdown */}
<div className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
{navLinks.map(link => (
<Link
key={link.to}
to={link.to}
className={`nav-mobile-link ${location.pathname === link.to ? 'active' : ''}`}
onClick={closeMenu}
>
{link.label}
</Link>
))}
</div>
</nav>
)
}
@@ -0,0 +1,79 @@
.newsletter {
max-width: 720px;
margin: 0 auto 60px;
padding: 40px;
text-align: center;
}
.newsletter h2 {
font-size: 1.6rem;
margin-bottom: 12px;
}
.newsletter-desc {
color: var(--text-secondary);
margin: 0 auto 28px;
max-width: 520px;
line-height: 1.6;
}
.newsletter-options {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
}
.newsletter-linkedin {
display: inline-flex;
align-items: center;
gap: 8px;
}
.newsletter-email {
width: 100%;
max-width: 440px;
}
.newsletter-input-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--glass-border);
border-radius: var(--radius-pill);
padding: 4px 4px 4px 16px;
}
.newsletter-input-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.newsletter-input-row input {
flex: 1;
min-width: 0;
background: none;
border: none;
color: var(--text-primary);
font-size: 1rem;
padding: 10px 4px;
outline: none;
}
.newsletter-input-row .secondary-button {
flex-shrink: 0;
white-space: nowrap;
}
.newsletter-error {
color: #ff4444;
font-size: 0.85rem;
margin: 8px 0 0;
}
.newsletter-success {
color: var(--text-primary);
margin: 0;
line-height: 1.6;
}
@@ -0,0 +1,77 @@
import React, { useState } from 'react'
import { ExternalLink, Mail } from 'lucide-react'
import { useFormSubmit } from '../hooks/useFormSubmit'
import './NewsletterSignup.css'
function validate(data) {
const errors = {}
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) errors.email = 'Gültige E-Mail erforderlich.'
return { valid: Object.keys(errors).length === 0, errors }
}
export default function NewsletterSignup({ newsletter }) {
const [form, setForm] = useState({ email: '', company_website: '' })
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/newsletter', validate)
if (!newsletter) return null
async function handleSubmit(e) {
e.preventDefault()
await submit(form)
}
return (
<div className="newsletter glass-panel">
<h2>{newsletter.title}</h2>
{newsletter.description && <p className="newsletter-desc">{newsletter.description}</p>}
<div className="newsletter-options">
{newsletter.linkedin_url && (
<a
href={newsletter.linkedin_url}
target="_blank"
rel="noopener noreferrer"
className="primary-button newsletter-linkedin"
>
<ExternalLink size={18} /> Auf LinkedIn abonnieren
</a>
)}
{newsletter.email_signup && (
<form className="newsletter-email" onSubmit={handleSubmit}>
{/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
<label>
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 }))} />
</label>
</div>
{success ? (
<p className="newsletter-success">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p>
) : (
<>
<div className="newsletter-input-row">
<Mail size={18} className="newsletter-input-icon" />
<input
type="email"
name="email"
placeholder="Per E-Mail abonnieren"
value={form.email}
onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))}
aria-label="E-Mail-Adresse"
/>
<button type="submit" className="secondary-button" disabled={loading}>
{loading ? '...' : 'Abonnieren'}
</button>
</div>
{errors.email && <p className="newsletter-error">{errors.email}</p>}
{serverError && <p className="newsletter-error">{serverError}</p>}
</>
)}
</form>
)}
</div>
</div>
)
}
@@ -0,0 +1,26 @@
import React from 'react'
/**
* Full-width, infinitely scrolling photo band.
*
* The CSS animation translates the track by -50%, so the image sequence MUST be
* rendered exactly twice for the loop to be seamless. This component guarantees
* that invariant by duplicating the `images` array, instead of relying on
* hand-maintained markup.
*/
export default function PhotoBand({ images = [] }) {
if (images.length === 0) return null
// Duplicate the sequence so translateX(-50%) lands on an identical frame.
const loop = [...images, ...images]
return (
<section className="photo-band" data-testid="photo-band">
<div className="photo-band-track">
{loop.map((src, i) => (
<img key={i} src={src} alt="" aria-hidden="true" />
))}
</div>
</section>
)
}
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import PhotoBand from './PhotoBand'
const __dirname = dirname(fileURLToPath(import.meta.url))
describe('PhotoBand', () => {
it('renders nothing when there are no images', () => {
const { container } = render(<PhotoBand images={[]} />)
expect(container.querySelector('.photo-band')).toBeNull()
})
// The CSS animation translates the track by -50%. If the sequence is not
// rendered exactly twice, the loop "jumps" instead of being seamless.
it('renders the image sequence exactly twice for a seamless loop', () => {
const images = ['/a.jpg', '/b.jpg', '/c.jpg']
const { container } = render(<PhotoBand images={images} />)
const rendered = [...container.querySelectorAll('.photo-band-track img')].map(img =>
img.getAttribute('src')
)
expect(rendered).toHaveLength(images.length * 2)
// First half and second half must be identical and in original order.
expect(rendered.slice(0, images.length)).toEqual(images)
expect(rendered.slice(images.length)).toEqual(images)
})
it('keeps the duplication invariant for any non-empty image list', () => {
for (const n of [1, 2, 4, 7]) {
const images = Array.from({ length: n }, (_, i) => `/img-${i}.jpg`)
const { container } = render(<PhotoBand images={images} />)
const count = container.querySelectorAll('.photo-band-track img').length
expect(count).toBe(n * 2)
}
})
// Regression guard for the freeze bug: `width: auto` made the track width
// depend on image decode timing, so before images loaded the track had ~0
// width and translateX(-50%) appeared frozen. The track image must have a
// deterministic width that does not depend on the loaded image.
it('does not use width:auto for band images (freeze-bug guard)', () => {
const css = readFileSync(join(__dirname, '..', 'pages', 'Home.css'), 'utf-8')
const imgRule = css.match(/\.photo-band-track img\s*\{([^}]*)\}/)
expect(imgRule).not.toBeNull()
const body = imgRule[1]
expect(body).not.toMatch(/width:\s*auto/)
expect(body).toMatch(/width:\s*\d/)
})
})
@@ -0,0 +1,8 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
export default function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
return null
}
@@ -0,0 +1,100 @@
.service-accordion {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 900px;
margin: 0 auto;
}
.accordion-item {
overflow: hidden;
transition: border-color var(--transition-fast);
}
.accordion-item.open {
border-color: rgba(var(--accent-color-rgb), 0.3);
}
.accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 24px 32px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-primary);
text-align: left;
}
.accordion-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.accordion-number {
font-size: 0.85rem;
color: var(--accent-color);
font-weight: var(--font-weight-bold);
min-width: 28px;
}
.accordion-header h3 {
font-size: 1.2rem;
font-weight: var(--font-weight-semibold);
}
.accordion-chevron {
color: var(--text-secondary);
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.accordion-chevron.rotated {
transform: rotate(180deg);
}
.accordion-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0.16, 1, 0.3, 1), padding 0.3s ease;
padding: 0 32px;
}
.accordion-body.expanded {
max-height: 500px;
padding: 0 32px 32px;
}
.accordion-description {
color: var(--text-secondary);
line-height: 1.7;
margin: 0 0 16px;
}
.accordion-list {
list-style: none;
padding: 0;
margin: 0;
}
.accordion-list li {
color: var(--text-secondary);
padding: 6px 0;
padding-left: 20px;
position: relative;
line-height: 1.5;
}
.accordion-list li::before {
content: '';
position: absolute;
left: 0;
top: 14px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-color);
}
@@ -0,0 +1,50 @@
import React, { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import './ServiceAccordion.css'
/**
* Expandable service/offering categories (à la Gentsch Leistungsportfolio).
*/
export default function ServiceAccordion({ services = [] }) {
const [openIndex, setOpenIndex] = useState(0)
if (services.length === 0) return null
return (
<div className="service-accordion">
{services.map((service, i) => (
<div
key={i}
className={`accordion-item glass-panel ${openIndex === i ? 'open' : ''}`}
>
<button
className="accordion-header"
onClick={() => setOpenIndex(openIndex === i ? -1 : i)}
aria-expanded={openIndex === i}
>
<div className="accordion-header-left">
<span className="accordion-number">{String(i + 1).padStart(2, '0')}</span>
<h3>{service.title}</h3>
</div>
<ChevronDown size={20} className={`accordion-chevron ${openIndex === i ? 'rotated' : ''}`} />
</button>
<div className={`accordion-body ${openIndex === i ? 'expanded' : ''}`}>
<p className="accordion-description">{service.description}</p>
{service.items && (
<ul className="accordion-list">
{service.items.map((item, j) => (
<li key={j}>{item}</li>
))}
</ul>
)}
{service.cta && (
<a href={service.cta.href} className="secondary-button" style={{ marginTop: '16px', display: 'inline-block' }}>
{service.cta.label}
</a>
)}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,95 @@
import React, { useState } from 'react'
import { FileDown } from 'lucide-react'
import { useFormSubmit } from '../hooks/useFormSubmit'
function validate(data) {
const errors = {}
if (!data.name || data.name.trim().length === 0) errors.name = 'Name ist erforderlich.'
if (data.name && data.name.length > 100) errors.name = 'Max. 100 Zeichen.'
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) errors.email = 'Gültige E-Mail erforderlich.'
if (!data.message || data.message.trim().length === 0) errors.message = 'Bitte sag kurz, wer du bist.'
if (data.message && data.message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
return { valid: Object.keys(errors).length === 0, errors }
}
const inputStyle = (hasError) => ({
width: '100%', padding: '12px', background: 'var(--bg-elevated)',
border: hasError ? '1px solid #ff4444' : '1px solid var(--glass-border)',
borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem',
})
export default function SpeakerCvRequest() {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({ name: '', email: '', message: '', company_website: '' })
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/speaker-cv', validate)
function handleChange(e) {
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
}
async function handleSubmit(e) {
e.preventDefault()
await submit(form)
}
if (!open) {
return (
<button type="button" className="primary-button" onClick={() => setOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<FileDown size={18} /> Speaker CV anfragen
</button>
)
}
return (
<div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}>
{success ? (
<p style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Der Speaker CV ist auf dem Weg in dein Postfach.
</p>
) : (
<form onSubmit={handleSubmit}>
<p style={{ marginTop: 0, marginBottom: '20px', color: 'var(--text-secondary)' }}>
Sag mir kurz wer Du bist und warum Du mich kennenlernen möchtest:
</p>
{/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
<label>
Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
</label>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
<input name="name" value={form.name} onChange={handleChange} style={inputStyle(errors.name)} />
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
</div>
<div style={{ marginBottom: '16px' }}>
<label 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)} />
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
</div>
<div style={{ marginBottom: '20px' }}>
<label 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' }} />
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
</div>
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
<div style={{ display: 'flex', gap: '12px' }}>
<button type="submit" className="primary-button" disabled={loading}>
{loading ? 'Wird gesendet...' : 'Speaker CV anfordern'}
</button>
<button type="button" className="secondary-button" onClick={() => setOpen(false)}>
Abbrechen
</button>
</div>
</form>
)}
</div>
)
}
@@ -0,0 +1,133 @@
.speaking-accordion {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 900px;
margin: 0 auto;
}
.speaking-section {
padding: 32px;
}
/* 2/3 text : 1/3 photo side by side */
.speaking-section-top {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 28px;
align-items: stretch;
}
.speaking-section-main {
min-width: 0;
}
.speaking-section-photo {
border-radius: var(--radius-md);
overflow: hidden;
min-height: 200px;
}
.speaking-section-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(0.85);
transition: filter var(--transition-fast), transform var(--transition-smooth);
}
.speaking-section:hover .speaking-section-photo img {
filter: brightness(1);
transform: scale(1.03);
}
@media (max-width: 640px) {
.speaking-section-top {
grid-template-columns: 1fr;
}
.speaking-section-photo {
min-height: 180px;
order: -1;
}
}
.speaking-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-primary);
text-align: left;
}
.speaking-section-header h2 {
font-size: 1.5rem;
margin: 0;
}
.speaking-chevron {
color: var(--accent-color);
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.speaking-chevron.open {
transform: rotate(180deg);
}
.speaking-topics {
margin: 20px 0 0;
padding-left: 20px;
color: var(--text-secondary);
}
.speaking-topics li {
margin: 6px 0;
line-height: 1.5;
}
.speaking-examples {
margin-top: 24px;
border-top: 1px solid var(--glass-border);
padding-top: 24px;
display: flex;
flex-direction: column;
gap: 24px;
animation: fadeIn 0.3s ease;
}
.speaking-example h3 {
font-size: 1.1rem;
margin: 0 0 8px;
}
.speaking-example-desc {
color: var(--text-secondary);
margin: 0 0 12px;
line-height: 1.6;
}
.speaking-example-events {
display: flex;
flex-direction: column;
gap: 4px;
}
.speaking-example-events span {
color: var(--text-secondary);
font-size: 0.9rem;
}
.speaking-empty {
color: var(--text-secondary);
margin: 0;
}
.speaking-section-actions {
margin-top: 24px;
}
@@ -0,0 +1,83 @@
import React, { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { formatDate } from '../utils/formatDate'
import './SpeakingAccordion.css'
/**
* One collapsible section per speaking format (Keynote / Workshop / Panel).
*
* Collapsed: heading, possible topics (bullet points), "Anfragen" button.
* Expanded: concrete examples (talks of that format, with past events).
*/
export default function SpeakingAccordion({ sections = [], talks = [] }) {
const [openFormat, setOpenFormat] = useState(null)
return (
<div className="speaking-accordion">
{sections.map((section) => {
const isOpen = openFormat === section.format
const examples = talks.filter(t => t.format === section.format)
return (
<div key={section.format} className="speaking-section glass-panel">
<div className="speaking-section-top">
<div className="speaking-section-main">
<button
type="button"
className="speaking-section-header"
aria-expanded={isOpen}
onClick={() => setOpenFormat(isOpen ? null : section.format)}
>
<h2>{section.heading}</h2>
<ChevronDown size={22} className={`speaking-chevron ${isOpen ? 'open' : ''}`} />
</button>
{(section.topics?.length > 0) && (
<ul className="speaking-topics">
{section.topics.map((topic, i) => (
<li key={i}>{topic}</li>
))}
</ul>
)}
<div className="speaking-section-actions">
<a href="/kontakt" className="primary-button">Anfragen</a>
</div>
</div>
{section.image && (
<div className="speaking-section-photo">
<img src={section.image} alt={section.heading} loading="lazy" />
</div>
)}
</div>
{isOpen && (
<div className="speaking-examples">
{examples.length === 0 ? (
<p className="speaking-empty">Beispiele auf Anfrage.</p>
) : (
examples.map((talk) => (
<div key={talk.id || talk.slug} className="speaking-example">
<h3>{talk.title}</h3>
<p className="speaking-example-desc">{talk.description}</p>
{talk.events?.length > 0 && (
<div className="speaking-example-events">
{talk.events.map((ev, i) => (
<span key={i}>
{ev.name} · {ev.location} · {formatDate(ev.date, { year: 'numeric', month: 'short' })}
</span>
))}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,75 @@
.cube-section {
display: flex;
justify-content: center;
align-items: center;
padding: 80px 20px;
perspective: 1200px;
}
.cube-container {
width: 260px;
height: 260px;
position: relative;
transform-style: preserve-3d;
animation: spinCube 16s infinite cubic-bezier(0.25, 0.1, 0.25, 1);
}
.cube-face {
position: absolute;
width: 260px;
height: 260px;
background: rgba(15, 15, 15, 0.95);
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-shadow: 0 0 40px rgba(var(--accent-color-rgb), 0.1) inset, 0 10px 40px rgba(0, 0, 0, 0.5);
backface-visibility: hidden;
border-radius: var(--radius-md);
}
.face-1 { transform: rotateY(0deg) translateZ(130px); }
.face-2 { transform: rotateY(90deg) translateZ(130px); }
.face-3 { transform: rotateY(180deg) translateZ(130px); }
.face-4 { transform: rotateY(-90deg) translateZ(130px); }
@keyframes spinCube {
0%, 15% { transform: rotateY(0deg); }
25%, 40% { transform: rotateY(-90deg); }
50%, 65% { transform: rotateY(-180deg); }
75%, 90% { transform: rotateY(-270deg); }
100% { transform: rotateY(-360deg); }
}
.cube-stat-value {
font-size: 3.5rem;
font-weight: var(--font-weight-bold);
margin-bottom: 10px;
filter: drop-shadow(0 0 15px rgba(var(--accent-color-rgb), 0.5));
}
.cube-stat-label {
font-size: 1.2rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 2px;
}
@media (max-width: 600px) {
.cube-container {
width: 200px;
height: 200px;
}
.cube-face {
width: 200px;
height: 200px;
}
.face-1 { transform: rotateY(0deg) translateZ(100px); }
.face-2 { transform: rotateY(90deg) translateZ(100px); }
.face-3 { transform: rotateY(180deg) translateZ(100px); }
.face-4 { transform: rotateY(-90deg) translateZ(100px); }
.cube-stat-value { font-size: 2.5rem; }
.cube-stat-label { font-size: 1rem; }
}
@@ -0,0 +1,23 @@
import React from 'react'
import { useMeta } from '../hooks/useContent'
import './StatsCube.css'
export default function StatsCube() {
const { stats } = useMeta()
if (!stats?.items || stats.items.length < 4) return null
const faces = stats.items.slice(0, 4)
return (
<section className="cube-section">
<div className="cube-container">
{faces.map((stat, i) => (
<div className={`cube-face face-${i + 1}`} key={i}>
<div className="cube-stat-value text-gradient">{stat.value}</div>
<div className="cube-stat-label">{stat.label}</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,84 @@
.testimonials-section {
padding: var(--section-padding) 20px;
}
.testimonial-card {
max-width: 800px;
margin: 0 auto;
padding: 60px 48px;
text-align: center;
position: relative;
}
.testimonial-quote {
position: relative;
margin-bottom: 32px;
}
.quote-mark {
position: absolute;
top: -20px;
left: -10px;
font-size: 5rem;
color: var(--accent-color);
opacity: 0.3;
font-family: Georgia, serif;
line-height: 1;
}
.testimonial-quote p {
font-size: 1.2rem;
line-height: 1.7;
color: var(--text-primary);
font-style: italic;
margin: 0;
}
.testimonial-author {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.author-photo {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.testimonial-author strong {
display: block;
color: var(--text-primary);
font-size: 0.95rem;
}
.testimonial-author span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.testimonial-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 24px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--glass-border);
border: none;
cursor: pointer;
transition: all var(--transition-fast);
padding: 0;
}
.dot.active {
background: var(--accent-color);
box-shadow: 0 0 8px var(--accent-color);
transform: scale(1.3);
}
@@ -0,0 +1,52 @@
import React, { useState, useEffect } from 'react'
import './TestimonialSlider.css'
/**
* Auto-rotating testimonial slider with quotes.
* Testimonials are passed as props (from content/meta/testimonials.yaml later).
*/
export default function TestimonialSlider({ testimonials = [] }) {
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
if (testimonials.length <= 1) return
const interval = setInterval(() => {
setActiveIndex(prev => (prev + 1) % testimonials.length)
}, 6000)
return () => clearInterval(interval)
}, [testimonials.length])
if (testimonials.length === 0) return null
const current = testimonials[activeIndex]
return (
<section className="testimonials-section app-container">
<div className="testimonial-card glass-panel">
<div className="testimonial-quote">
<span className="quote-mark">"</span>
<p>{current.quote}</p>
</div>
<div className="testimonial-author">
{current.photo && <img src={current.photo} alt={current.name} className="author-photo" />}
<div>
<strong>{current.name}</strong>
<span>{current.role}</span>
</div>
</div>
{testimonials.length > 1 && (
<div className="testimonial-dots">
{testimonials.map((_, i) => (
<button
key={i}
className={`dot ${i === activeIndex ? 'active' : ''}`}
onClick={() => setActiveIndex(i)}
aria-label={`Testimonial ${i + 1}`}
/>
))}
</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,62 @@
.upcoming-section {
padding: var(--section-padding) 20px;
}
.upcoming-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.upcoming-card {
padding: 28px;
transition: transform var(--transition-smooth), border-color var(--transition-fast);
}
.upcoming-card:hover {
transform: translateY(-4px);
border-color: rgba(var(--accent-color-rgb), 0.3);
}
.upcoming-date {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent-color);
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
margin-bottom: 12px;
}
.upcoming-title {
font-size: 1.1rem;
font-weight: var(--font-weight-semibold);
margin-bottom: 8px;
}
.upcoming-desc {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0 0 12px;
line-height: 1.5;
}
.upcoming-location {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
}
.upcoming-type {
display: inline-block;
margin-top: 10px;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: rgba(var(--accent-color-rgb), 0.1);
color: var(--accent-color);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
}
@@ -0,0 +1,46 @@
import React from 'react'
import { Calendar, MapPin } from 'lucide-react'
import { useContent } from '../hooks/useContent'
import { formatDate, isFuture } from '../utils/formatDate'
import './UpcomingEvents.css'
/**
* Upcoming events section for the homepage.
* Shows only future events, sorted by date ascending (next event first).
*/
export default function UpcomingEvents() {
const { items: allEvents } = useContent('events', { pageSize: 100 })
const upcoming = allEvents
.filter(ev => isFuture(ev.date))
.sort((a, b) => new Date(a.date) - new Date(b.date))
.slice(0, 4)
if (upcoming.length === 0) return null
return (
<section className="upcoming-section app-container">
<div className="section-header">
<h2 className="section-title">Nächste <span className="text-gradient">Termine</span></h2>
</div>
<div className="upcoming-grid">
{upcoming.map(event => (
<div key={event.id || event.slug} className="upcoming-card glass-panel">
<div className="upcoming-date">
<Calendar size={16} />
<span>{formatDate(event.date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
<h3 className="upcoming-title">{event.title}</h3>
{event.description && <p className="upcoming-desc">{event.description}</p>}
{event.location && (
<div className="upcoming-location">
<MapPin size={14} />
<span>{event.location}</span>
</div>
)}
{event.event_type && <span className="upcoming-type">{event.event_type}</span>}
</div>
))}
</div>
</section>
)
}