feat(privat/CV): sync to latest upstream (cv-upstream/main, 30f9608b)
This commit is contained in:
@@ -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">
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user