feat(privat/CV): sync to latest upstream (cv-upstream/main, 30f9608b)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Navigation from './components/Navigation'
|
||||
import Footer from './components/Footer'
|
||||
import BackgroundPattern from './components/BackgroundPattern'
|
||||
import BottomDock from './components/BottomDock'
|
||||
import ScrollToTop from './components/ScrollToTop'
|
||||
import Home from './pages/Home'
|
||||
import Kniepunkt from './pages/Kniepunkt'
|
||||
import KniepunktIssue from './pages/KniepunktIssue'
|
||||
import Podcast from './pages/Podcast'
|
||||
import Speaking from './pages/Speaking'
|
||||
import Consulting from './pages/Consulting'
|
||||
import Resources from './pages/Resources'
|
||||
import Contact from './pages/Contact'
|
||||
import Impressum from './pages/Impressum'
|
||||
import Datenschutz from './pages/Datenschutz'
|
||||
import About from './pages/About'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<div className="app-root">
|
||||
<BackgroundPattern />
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/kniepunkt" element={<Kniepunkt />} />
|
||||
<Route path="/kniepunkt/:slug" element={<KniepunktIssue />} />
|
||||
<Route path="/podcast" element={<Podcast />} />
|
||||
<Route path="/speaking" element={<Speaking />} />
|
||||
<Route path="/consulting" element={<Consulting />} />
|
||||
<Route path="/resources" element={<Resources />} />
|
||||
<Route path="/kontakt" element={<Contact />} />
|
||||
<Route path="/ueber-mich" element={<About />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
<BottomDock />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(__dirname, '..')
|
||||
|
||||
// Brand rule: the name is always written "André" with the accent on the e.
|
||||
// "Andre" without accent is only allowed in technical identifiers (file names,
|
||||
// emails, URLs, import paths) — never as the displayed name.
|
||||
describe('Brand: André accent', () => {
|
||||
it('profile.yaml uses "André" with accent', () => {
|
||||
const yaml = readFileSync(join(root, 'content', 'meta', 'profile.yaml'), 'utf-8')
|
||||
expect(yaml).toMatch(/Dr\. André Knie/)
|
||||
expect(yaml).not.toMatch(/Dr\. Andre Knie/)
|
||||
})
|
||||
|
||||
it('no display name "Dr. Andre Knie" without accent in source/content', () => {
|
||||
const files = [
|
||||
'src/pages/Home.jsx',
|
||||
'src/pages/About.jsx',
|
||||
'src/components/Footer.jsx',
|
||||
'src/components/Navigation.jsx',
|
||||
'src/pages/Impressum.jsx',
|
||||
'src/pages/Datenschutz.jsx',
|
||||
'index.html',
|
||||
]
|
||||
for (const f of files) {
|
||||
const content = readFileSync(join(root, f), 'utf-8')
|
||||
expect(content, `${f} must use "André" with accent`).not.toMatch(/Dr\. Andre Knie/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import content from 'virtual:content'
|
||||
|
||||
/**
|
||||
* Get all content of a given type, sorted by date descending.
|
||||
* @param {'posts'|'kniepunkt'|'podcast'|'talks'|'consulting'|'resources'|'events'} type
|
||||
* @param {object} options
|
||||
* @param {string} options.tag - Filter by tag
|
||||
* @param {'primary'|'secondary'|'all'} options.visibility - Filter by visibility (default: 'all')
|
||||
* @param {number} options.page - Page number (1-based)
|
||||
* @param {number} options.pageSize - Items per page (default: 10)
|
||||
*/
|
||||
export function useContent(type, options = {}) {
|
||||
const { tag, visibility = 'all', page = 1, pageSize = 10 } = options
|
||||
|
||||
let items = content[type] || []
|
||||
|
||||
// Filter by visibility
|
||||
if (visibility !== 'all') {
|
||||
items = items.filter(item => (item.visibility || 'primary') === visibility)
|
||||
}
|
||||
|
||||
// Filter by tag
|
||||
if (tag) {
|
||||
items = items.filter(item => item.tags && item.tags.includes(tag))
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
items = [...items].sort((a, b) => {
|
||||
const dateA = new Date(a.date || a.issue || 0)
|
||||
const dateB = new Date(b.date || b.issue || 0)
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
// Pagination
|
||||
const total = items.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const paginated = items.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
return {
|
||||
items: paginated,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single content item by slug.
|
||||
*/
|
||||
export function useContentItem(type, slug) {
|
||||
const items = content[type] || []
|
||||
return items.find(item => item.slug === slug) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique tags across a content type.
|
||||
*/
|
||||
export function useContentTags(type) {
|
||||
const items = content[type] || []
|
||||
const tagSet = new Set()
|
||||
items.forEach(item => {
|
||||
if (item.tags) item.tags.forEach(t => tagSet.add(t))
|
||||
})
|
||||
return [...tagSet].slice(0, 20).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured/primary content for homepage highlights.
|
||||
* Returns the latest N items with visibility=primary from each type.
|
||||
*/
|
||||
export function useFeaturedContent(count = 3) {
|
||||
const types = ['posts', 'kniepunkt', 'podcast', 'talks', 'consulting']
|
||||
const featured = {}
|
||||
|
||||
types.forEach(type => {
|
||||
const items = (content[type] || [])
|
||||
.filter(item => (item.visibility || 'primary') === 'primary')
|
||||
.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
|
||||
.slice(0, count)
|
||||
featured[type] = items
|
||||
})
|
||||
|
||||
return featured
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site metadata (profile, stats).
|
||||
*/
|
||||
export function useMeta() {
|
||||
return content.meta || {}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook for form submission with validation and loading state.
|
||||
* Automatically includes anti-spam fields (honeypot + form-render timestamp).
|
||||
* @param {string} endpoint - API endpoint (e.g., '/api/contact')
|
||||
* @param {function} validate - Validation function, returns { valid, errors }
|
||||
*/
|
||||
export function useFormSubmit(endpoint, validate) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState(null)
|
||||
// Timestamp when the form mounted — used as a server-side time trap.
|
||||
const formStartedAt = useRef(0)
|
||||
useEffect(() => {
|
||||
formStartedAt.current = Date.now()
|
||||
}, [])
|
||||
|
||||
async function submit(data) {
|
||||
setServerError(null)
|
||||
|
||||
// Client-side validation
|
||||
if (validate) {
|
||||
const result = validate(data)
|
||||
if (!result.valid) {
|
||||
setErrors(result.errors)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setErrors({})
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...data, form_started_at: formStartedAt.current }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (body.errors) {
|
||||
setErrors(body.errors)
|
||||
} else {
|
||||
setServerError(body.error || 'Ein Fehler ist aufgetreten.')
|
||||
}
|
||||
setLoading(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setLoading(false)
|
||||
return true
|
||||
} catch {
|
||||
setServerError('Verbindungsfehler. Bitte versuche es erneut.')
|
||||
setLoading(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setLoading(false)
|
||||
setSuccess(false)
|
||||
setErrors({})
|
||||
setServerError(null)
|
||||
}
|
||||
|
||||
return { submit, loading, success, errors, serverError, reset }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap');
|
||||
|
||||
/* ==========================================================================
|
||||
THEMING — Change these variables to retheme the entire site.
|
||||
All accent colors, backgrounds, and effects derive from these values.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Base colors — dark theme */
|
||||
--bg-color: #0a0a0a;
|
||||
--bg-elevated: #141414;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #999999;
|
||||
|
||||
/* Accent — CHANGE THESE to retheme the entire site */
|
||||
--accent-color: #4a9eff;
|
||||
--accent-color-rgb: 74, 158, 255;
|
||||
--accent-gradient: linear-gradient(135deg, #4a9eff, #2563eb);
|
||||
--accent-hover: #2563eb;
|
||||
|
||||
/* Glass morphism */
|
||||
--glass-bg: rgba(25, 25, 25, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Spacing */
|
||||
--section-padding: 120px;
|
||||
--container-max: 1200px;
|
||||
|
||||
/* Typography */
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 800;
|
||||
|
||||
/* Transitions */
|
||||
--transition-smooth: 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-fast: 0.3s ease;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--radius-pill: 50px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
RESET & BASE
|
||||
========================================================================== */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
UTILITY CLASSES
|
||||
========================================================================== */
|
||||
|
||||
.app-container {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
display: inline-block;
|
||||
background: var(--accent-gradient);
|
||||
color: #fff;
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Section layout */
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Background mesh effect */
|
||||
.background-mesh {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(var(--accent-color-rgb), 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 0% 0%, rgba(var(--accent-color-rgb), 0.03) 0%, transparent 40%),
|
||||
radial-gradient(circle at 100% 100%, rgba(var(--accent-color-rgb), 0.03) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeUp {
|
||||
0% { opacity: 0; transform: translateY(30px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import { useMeta } from '../hooks/useContent'
|
||||
import { ExternalLink, Globe } from 'lucide-react'
|
||||
import SpeakerCvRequest from '../components/SpeakerCvRequest'
|
||||
|
||||
export default function About() {
|
||||
const { profile, about } = useMeta()
|
||||
const links = about?.links || {}
|
||||
const stations = about?.stations || []
|
||||
const certifications = about?.certifications || []
|
||||
const personal = about?.personal || []
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '800px' }}>
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Über <span className="text-gradient">mich</span></h1>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<div className="glass-panel" style={{ padding: '48px', marginBottom: '40px', display: 'flex', gap: '32px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<img src="/images/andre-knie.png" alt="Dr. André Knie" style={{ width: '140px', height: '140px', borderRadius: '50%', objectFit: 'cover', border: '3px solid rgba(var(--accent-color-rgb), 0.3)' }} />
|
||||
<div style={{ flex: 1, minWidth: '250px' }}>
|
||||
<h2 style={{ marginBottom: '8px' }}>{profile?.name || 'Dr. André Knie'}</h2>
|
||||
<p style={{ color: 'var(--accent-color)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
{profile?.roles?.join(' · ')}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7 }}>
|
||||
{profile?.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '40px', alignItems: 'flex-start' }}>
|
||||
{links.linkedin && (
|
||||
<a href={links.linkedin} target="_blank" rel="noopener noreferrer" className="secondary-button" style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||||
<ExternalLink size={18} /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
{links.dhive && (
|
||||
<a href={links.dhive} target="_blank" rel="noopener noreferrer" className="secondary-button" style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Globe size={18} /> Data Hive Cassel
|
||||
</a>
|
||||
)}
|
||||
<SpeakerCvRequest />
|
||||
</div>
|
||||
|
||||
{/* Career Timeline */}
|
||||
{stations.length > 0 && (
|
||||
<>
|
||||
<h2 style={{ marginBottom: '24px' }}>Stationen</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '60px' }}>
|
||||
{stations.map((entry, i) => (
|
||||
<div key={i} className="glass-panel" style={{ padding: '20px 28px', display: 'flex', gap: '20px', alignItems: 'baseline' }}>
|
||||
<span style={{ color: 'var(--accent-color)', fontSize: '0.85rem', fontWeight: 'var(--font-weight-semibold)', minWidth: '90px', whiteSpace: 'nowrap' }}>{entry.period}</span>
|
||||
<div>
|
||||
<strong>{entry.title}</strong>
|
||||
<span style={{ color: 'var(--text-secondary)', marginLeft: '8px' }}>— {entry.org}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Certifications */}
|
||||
{certifications.length > 0 && (
|
||||
<>
|
||||
<h2 style={{ marginBottom: '24px' }}>Zertifizierungen & Weiterbildungen</h2>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginBottom: '60px' }}>
|
||||
{certifications.map((cert, i) => (
|
||||
<span key={i} style={{ background: 'rgba(var(--accent-color-rgb), 0.1)', color: 'var(--accent-color)', padding: '8px 16px', borderRadius: 'var(--radius-pill)', fontSize: '0.85rem' }}>
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Personal */}
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<h2 style={{ marginBottom: '24px' }}>Engagement & Persönliches</h2>
|
||||
<div className="glass-panel" style={{ padding: '28px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{personal.map((para, i) => (
|
||||
<p key={i} style={{ margin: i === personal.length - 1 ? 0 : '0 0 12px' }}>{para}</p>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { useContent, useMeta } from '../hooks/useContent'
|
||||
import ServiceAccordion from '../components/ServiceAccordion'
|
||||
|
||||
export default function Consulting() {
|
||||
const { items } = useContent('consulting', { pageSize: 10 })
|
||||
const { services } = useMeta()
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Leistungen</span></h1>
|
||||
<p className="section-subtitle">Von der Strategie bis zur Umsetzung. Souverän, pragmatisch, evidenzbasiert.</p>
|
||||
</div>
|
||||
|
||||
{/* Service Accordion */}
|
||||
<ServiceAccordion services={services || []} />
|
||||
|
||||
{/* Concrete Examples */}
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginTop: '80px' }}>
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Beispiele aus der <span className="text-gradient">Praxis</span></h2>
|
||||
<p className="section-subtitle">Anonymisiert, aber konkret.</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '24px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
{items.map(example => (
|
||||
<div key={example.id || example.slug} className="glass-panel" style={{ padding: '32px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
{example.industry && <span style={{ background: 'rgba(var(--accent-color-rgb), 0.1)', color: 'var(--accent-color)', padding: '4px 10px', borderRadius: 'var(--radius-sm)', fontSize: '0.8rem' }}>{example.industry}</span>}
|
||||
{example.organization_size && <span style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-secondary)', padding: '4px 10px', borderRadius: 'var(--radius-sm)', fontSize: '0.8rem' }}>{example.organization_size} MA</span>}
|
||||
</div>
|
||||
<h3 style={{ marginBottom: '12px', fontSize: '1.2rem' }}>{example.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', marginBottom: '8px' }}><strong style={{ color: 'var(--text-primary)' }}>Problem:</strong> {example.problem_domain}</p>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', marginBottom: '8px' }}><strong style={{ color: 'var(--text-primary)' }}>Ansatz:</strong> {example.approach}</p>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', marginTop: 'auto' }}><strong style={{ color: 'var(--accent-color)' }}>Ergebnis:</strong> {example.outcomes}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useFormSubmit } from '../hooks/useFormSubmit'
|
||||
import { validateContact } from '../utils/validation'
|
||||
|
||||
export default function Contact() {
|
||||
const [form, setForm] = useState({ name: '', email: '', message: '', company_website: '' })
|
||||
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/contact', validateContact)
|
||||
|
||||
function handleChange(e) {
|
||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
await submit(form)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
|
||||
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '600px' }}>
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Kontakt</span></h1>
|
||||
<p className="section-subtitle">Schreib mir. Ich melde mich.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '40px' }}>
|
||||
{/* Honeypot — hidden from real users, only bots fill this. */}
|
||||
<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: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
|
||||
<input name="name" value={form.name} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.name ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
|
||||
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
|
||||
<input name="email" type="email" value={form.email} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.email ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
|
||||
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Nachricht</label>
|
||||
<textarea name="message" value={form.message} onChange={handleChange} rows={6} maxLength={2000} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.message ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem', resize: 'vertical' }} />
|
||||
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', margin: '4px 0 0' }}>{form.message.length}/2000</p>
|
||||
</div>
|
||||
|
||||
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
|
||||
|
||||
<button type="submit" className="primary-button" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? 'Wird gesendet...' : 'Nachricht senden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Datenschutz() {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '700px' }}>
|
||||
<h1 style={{ marginBottom: '32px' }}>Datenschutzerklärung</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>1. Verantwortlicher</strong></p>
|
||||
<p>
|
||||
Dr. André Knie<br />
|
||||
c/o Data Hive Cassel GmbH<br />
|
||||
Universitätsplatz 12, 34127 Kassel<br />
|
||||
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
|
||||
</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Verarbeitung personenbezogener Daten</strong></p>
|
||||
<p>Die Nutzung dieser Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Personenbezogene Daten werden nur erhoben, wenn Sie diese aktiv über Formulare (Kontakt, Vortragsanfrage, Newsletter, Resource-Download) übermitteln.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>3. Zweck der Datenverarbeitung</strong></p>
|
||||
<p>Ihre Daten werden ausschließlich zur Bearbeitung Ihrer Anfrage bzw. zur Zustellung des Newsletters verwendet. Eine Weitergabe an Dritte ohne Ihre ausdrückliche Zustimmung erfolgt nicht.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>4. Rechtsgrundlage</strong></p>
|
||||
<p>Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw. zur Vertragsanbahnung (Art. 6 Abs. 1 lit. b DSGVO).</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>5. Newsletter (Double Opt-In)</strong></p>
|
||||
<p>Bei Anmeldung zum Newsletter wird Ihre E-Mail-Adresse gespeichert. Die Anmeldung erfolgt im Double-Opt-In-Verfahren: Sie erhalten eine Bestätigungs-E-Mail und müssen die Anmeldung aktiv bestätigen. Sie können das Abonnement jederzeit widerrufen.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>6. Kontaktformulare</strong></p>
|
||||
<p>Bei Nutzung der Kontakt- oder Vortragsanfrage-Formulare werden die übermittelten Daten (Name, E-Mail, Nachricht) zur Bearbeitung der Anfrage gespeichert. Eine Bestätigungs-E-Mail wird zur Verifizierung versendet. Nicht bestätigte Anfragen werden nach 48 Stunden gelöscht.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>7. Resource-Downloads & Speaker CV</strong></p>
|
||||
<p>Für den Download bestimmter Ressourcen sowie für die Anforderung des Speaker CV werden Name, E-Mail und Ihre Nachricht erhoben. Diese Daten werden zur Bearbeitung Ihrer Anfrage, zur Zusendung der angeforderten Unterlagen und zur Beziehungspflege verwendet.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting</strong></p>
|
||||
<p>Diese Webseite wird auf Servern in der EU gehostet. Es erfolgt keine Datenübertragung in Drittländer.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
|
||||
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies</strong></p>
|
||||
<p>Diese Webseite verwendet keine Tracking-Cookies und keine Analyse-Tools von Drittanbietern. Es werden ausschließlich technisch notwendige Funktionen genutzt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
text-align: center;
|
||||
padding: 140px 0 60px 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 900px;
|
||||
animation: fadeUp 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.hero-photo {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 24px;
|
||||
border: 3px solid rgba(var(--accent-color-rgb), 0.3);
|
||||
box-shadow: 0 0 30px rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2.5rem, 6vw, 4.5rem);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-roles {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 24px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.hero-subtext {
|
||||
font-size: clamp(1.1rem, 2vw, 1.3rem);
|
||||
color: var(--text-secondary);
|
||||
max-width: 700px;
|
||||
margin: 0 auto 40px auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Photo Band — full width scrolling images */
|
||||
.photo-band {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 40px 0;
|
||||
mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
||||
}
|
||||
|
||||
.photo-band-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
will-change: transform;
|
||||
animation: photoBandScroll 10s linear infinite;
|
||||
}
|
||||
|
||||
.photo-band-track img {
|
||||
height: 207px;
|
||||
width: 311px;
|
||||
aspect-ratio: 3 / 2;
|
||||
margin-right: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
filter: brightness(0.8);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.photo-band-track img:hover {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
@keyframes photoBandScroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Positioning — Mein Ansatz */
|
||||
.positioning-section {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.positioning-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.positioning-card {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
transition: transform var(--transition-smooth), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.positioning-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.positioning-card h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.positioning-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.positioning-icon-svg {
|
||||
color: var(--accent-color);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.positioning-icon-svg.dissolving {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.positioning-card p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Audiences */
|
||||
.audiences-section {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.audiences-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.audience-card {
|
||||
padding: 32px;
|
||||
transition: transform var(--transition-smooth), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.audience-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.audience-card h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 8px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.audience-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { useMeta } from '../hooks/useContent'
|
||||
import LogoMarquee from '../components/LogoMarquee'
|
||||
import TestimonialSlider from '../components/TestimonialSlider'
|
||||
import UpcomingEvents from '../components/UpcomingEvents'
|
||||
import StatsCube from '../components/StatsCube'
|
||||
import DotCloudIcon from '../components/DotCloudIcon'
|
||||
import PhotoBand from '../components/PhotoBand'
|
||||
import './Home.css'
|
||||
|
||||
const PHOTO_BAND_IMAGES = [
|
||||
'/images/workshop-1.jpg',
|
||||
'/images/keynote-1.jpg',
|
||||
'/images/workshop-2.jpg',
|
||||
'/images/panel-1.jpg',
|
||||
'/images/workshop-3.jpg',
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
const { profile, testimonials, logos } = useMeta()
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="app-container hero-content">
|
||||
<img src="/images/andre-knie.png" alt="Dr. André Knie" className="hero-photo" />
|
||||
<h1>
|
||||
{profile?.name || 'Dr. André Knie'}
|
||||
</h1>
|
||||
<p className="hero-roles">
|
||||
{profile?.roles?.join(' · ')}
|
||||
</p>
|
||||
<p className="hero-subtext">
|
||||
{profile?.summary}
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a href="/speaking" className="primary-button">Speaking anfragen</a>
|
||||
<a href="/kontakt" className="secondary-button">Kontakt</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Cube */}
|
||||
<StatsCube />
|
||||
|
||||
{/* Photo Band — full width, no text, just scrolling images */}
|
||||
<PhotoBand images={PHOTO_BAND_IMAGES} />
|
||||
|
||||
{/* Positioning — Mein Ansatz */}
|
||||
<section className="positioning-section">
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Mein <span className="text-gradient">Ansatz</span></h2>
|
||||
</div>
|
||||
<div className="positioning-grid">
|
||||
<div className="positioning-card glass-panel">
|
||||
<DotCloudIcon shape="germany" size={120} />
|
||||
<h3>Souveränität</h3>
|
||||
<p>{profile?.positioning?.sovereignty}</p>
|
||||
</div>
|
||||
<div className="positioning-card glass-panel">
|
||||
<DotCloudIcon shape="shield" size={120} />
|
||||
<h3>Sicher von KI profitieren</h3>
|
||||
<p>{profile?.positioning?.data_sovereignty}</p>
|
||||
</div>
|
||||
<div className="positioning-card glass-panel">
|
||||
<DotCloudIcon shape="tools" size={120} />
|
||||
<h3>Praxis</h3>
|
||||
<p>{profile?.positioning?.practical}</p>
|
||||
</div>
|
||||
<div className="positioning-card glass-panel">
|
||||
<DotCloudIcon shape="person" size={120} />
|
||||
<h3>Mensch im Zentrum</h3>
|
||||
<p>KI ist kein Selbstzweck, sondern unterstützt Menschen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audiences */}
|
||||
{profile?.audiences && (
|
||||
<section className="audiences-section">
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Für wen ich <span className="text-gradient">arbeite</span></h2>
|
||||
</div>
|
||||
<div className="audiences-grid">
|
||||
{profile.audiences.map((aud) => (
|
||||
<div className="audience-card glass-panel" key={aud.id}>
|
||||
<h3>{aud.label}</h3>
|
||||
<p>{aud.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Logo Marquee — Kunden & Partner */}
|
||||
<LogoMarquee
|
||||
title="Kunden &"
|
||||
titleAccent="Partner"
|
||||
variant="light"
|
||||
logos={logos?.light || []}
|
||||
/>
|
||||
<LogoMarquee
|
||||
variant="dark"
|
||||
logos={logos?.dark || []}
|
||||
/>
|
||||
|
||||
{/* Testimonials */}
|
||||
<TestimonialSlider testimonials={testimonials || []} />
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<UpcomingEvents />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Impressum() {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '700px' }}>
|
||||
<h1 style={{ marginBottom: '32px' }}>Impressum</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>Angaben gemäß § 5 TMG</strong></p>
|
||||
<p>
|
||||
Dr. André Knie<br />
|
||||
c/o Data Hive Cassel GmbH<br />
|
||||
Universitätsplatz 12<br />
|
||||
34127 Kassel
|
||||
</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>Kontakt</strong></p>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
|
||||
</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</strong></p>
|
||||
<p>Dr. André Knie (Anschrift wie oben)</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>Haftungsausschluss</strong></p>
|
||||
<p><em>Haftung für Inhalte</em></p>
|
||||
<p>Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Als Diensteanbieter bin ich gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG bin ich als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||||
|
||||
<p><em>Haftung für Links</em></p>
|
||||
<p>Dieses Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann für diese fremden Inhalte auch keine Gewähr übernommen werden. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.</p>
|
||||
|
||||
<p><em>Urheberrecht</em></p>
|
||||
<p>Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useContent, useContentTags } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
|
||||
export default function Kniepunkt() {
|
||||
const [tag, setTag] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const { items, totalPages, hasNext, hasPrev } = useContent('kniepunkt', { tag, page })
|
||||
const tags = useContentTags('kniepunkt')
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Kniepunkt</span></h1>
|
||||
<p className="section-subtitle">Wöchentliche KI-Kolumne. Nachrichten und Absurditäten rund um KI.</p>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '40px' }}>
|
||||
<button onClick={() => setTag(null)} className={`secondary-button ${!tag ? 'active' : ''}`} style={{ padding: '8px 16px', fontSize: '0.85rem' }}>Alle</button>
|
||||
{tags.map(t => (
|
||||
<button key={t} onClick={() => { setTag(t); setPage(1) }} className={`secondary-button ${tag === t ? 'active' : ''}`} style={{ padding: '8px 16px', fontSize: '0.85rem' }}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '700px', margin: '0 auto' }}>
|
||||
{items.map(issue => (
|
||||
<Link to={`/kniepunkt/${issue.slug}`} key={issue.slug} className="glass-panel" style={{ padding: '24px', display: 'block', transition: 'transform 0.3s ease' }}>
|
||||
<h3 style={{ marginBottom: '8px' }}>{issue.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{formatDate(issue.date)}</p>
|
||||
{issue.summary && <p style={{ color: 'var(--text-secondary)', margin: '8px 0 0', fontSize: '0.95rem' }}>{issue.summary}</p>}
|
||||
</Link>
|
||||
))}
|
||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Noch keine Ausgaben vorhanden.</p>}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '16px', marginTop: '40px' }}>
|
||||
{hasPrev && <button onClick={() => setPage(p => p - 1)} className="secondary-button">Zurück</button>}
|
||||
<span style={{ color: 'var(--text-secondary)', alignSelf: 'center' }}>Seite {page} / {totalPages}</span>
|
||||
{hasNext && <button onClick={() => setPage(p => p + 1)} className="secondary-button">Weiter</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useContentItem } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
|
||||
export default function KniepunktIssue() {
|
||||
const { slug } = useParams()
|
||||
const issue = useContentItem('kniepunkt', slug)
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ textAlign: 'center' }}>
|
||||
<h1>Ausgabe nicht gefunden</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Diese Kniepunkt-Ausgabe existiert nicht.</p>
|
||||
<Link to="/kniepunkt" className="secondary-button">Zurück zur Übersicht</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '700px' }}>
|
||||
<Link to="/kniepunkt" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '24px', display: 'inline-block' }}>← Alle Ausgaben</Link>
|
||||
<h1 style={{ marginBottom: '12px' }}>{issue.title}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '40px' }}>{formatDate(issue.date)}</p>
|
||||
<div className="content-body" dangerouslySetInnerHTML={{ __html: issue.body }} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="app-container" style={{ textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '6rem', marginBottom: '16px' }}><span className="text-gradient">404</span></h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '1.2rem', marginBottom: '32px' }}>Diese Seite existiert nicht.</p>
|
||||
<Link to="/" className="primary-button">Zurück zur Startseite</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { useContent } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
|
||||
export default function Podcast() {
|
||||
const { items: allEpisodes } = useContent('podcast', { visibility: 'all', pageSize: 100 })
|
||||
|
||||
// Group: own show "Almost Intelligent" vs. guest appearances vs. other shows (Einfachbahn-Impulse)
|
||||
const almostIntelligent = allEpisodes.filter(ep => !ep.role || ep.show === 'Almost Intelligent')
|
||||
const guestAppearances = allEpisodes.filter(ep => ep.role === 'guest')
|
||||
const otherShows = allEpisodes.filter(ep => ep.role === 'host' && ep.show !== 'Almost Intelligent')
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Podcast</span></h1>
|
||||
<p className="section-subtitle">Eigener Podcast, Gastauftritte und mehr — zu KI, Transformation und Leadership.</p>
|
||||
</div>
|
||||
|
||||
{/* Almost Intelligent */}
|
||||
{almostIntelligent.length > 0 && (
|
||||
<section style={{ marginBottom: '60px' }}>
|
||||
<h2 style={{ marginBottom: '8px' }}>Almost Intelligent</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>14-tägiger KI-Podcast. Die Brücke zwischen künstlicher und natürlicher Intelligenz.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{almostIntelligent.map(ep => (
|
||||
<EpisodeCard key={ep.id || ep.slug} ep={ep} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Gastauftritte */}
|
||||
{guestAppearances.length > 0 && (
|
||||
<section style={{ marginBottom: '60px' }}>
|
||||
<h2 style={{ marginBottom: '8px' }}>Gastauftritte</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Einladungen zu anderen Podcasts und Formaten.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{guestAppearances.map(ep => (
|
||||
<EpisodeCard key={ep.id || ep.slug} ep={ep} showHost />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Einfachbahn-Impulse / andere eigene Shows */}
|
||||
{otherShows.length > 0 && (
|
||||
<section style={{ marginBottom: '60px' }}>
|
||||
<h2 style={{ marginBottom: '8px' }}>Weitere Formate</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Interne und spezialisierte Podcast-Reihen.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{otherShows.map(ep => (
|
||||
<EpisodeCard key={ep.id || ep.slug} ep={ep} showHost />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allEpisodes.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Noch keine Episoden veröffentlicht.</p>}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function EpisodeCard({ ep, showHost = false }) {
|
||||
const allLinks = { ...ep.external_links, ...ep.links }
|
||||
const linkEntries = Object.entries(allLinks || {}).filter(([, url]) => url)
|
||||
|
||||
return (
|
||||
<div className="glass-panel" style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '4px' }}>
|
||||
<h3 style={{ marginBottom: '4px' }}>{ep.title}</h3>
|
||||
{ep.show && showHost && <span style={{ color: 'var(--accent-color)', fontSize: '0.8rem', fontWeight: 'var(--font-weight-semibold)' }}>{ep.show}</span>}
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: '0 0 8px', fontSize: '0.9rem' }}>
|
||||
{formatDate(ep.date)} {ep.host && showHost && `· ${ep.host}`} {ep.duration && `· ${ep.duration}`}
|
||||
</p>
|
||||
{ep.description && <p style={{ color: 'var(--text-secondary)', margin: '0 0 12px', lineHeight: 1.6 }}>{ep.description}</p>}
|
||||
{ep.summary && !ep.description && <p style={{ color: 'var(--text-secondary)', margin: '0 0 12px', lineHeight: 1.6 }}>{ep.summary}</p>}
|
||||
{linkEntries.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{linkEntries.map(([name, url]) => (
|
||||
<a key={name} href={url} target="_blank" rel="noopener noreferrer" className="secondary-button" style={{ padding: '8px 14px', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ExternalLink size={14} /> {name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { useContent } from '../hooks/useContent'
|
||||
|
||||
export default function Resources() {
|
||||
const { items } = useContent('resources', { pageSize: 20 })
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1>
|
||||
<p className="section-subtitle">Praktische Materialien zum Download.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
||||
{items.map(res => (
|
||||
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
||||
</div>
|
||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { useContent, useMeta } from '../hooks/useContent'
|
||||
import SpeakingAccordion from '../components/SpeakingAccordion'
|
||||
|
||||
export default function Speaking() {
|
||||
const { items: allTalks } = useContent('talks', { visibility: 'all', pageSize: 100 })
|
||||
const { speaking } = useMeta()
|
||||
const sections = speaking?.sections || []
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Speaking</span></h1>
|
||||
<p className="section-subtitle">Keynotes, Workshops und Panels zu KI, Transformation und Leadership.</p>
|
||||
</div>
|
||||
|
||||
{/* Speaker Bio */}
|
||||
{speaking?.bio && (
|
||||
<div className="glass-panel" style={{ padding: '32px', maxWidth: '900px', margin: '0 auto 48px', display: 'flex', gap: '28px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<img src="/images/andre-knie.png" alt="Dr. André Knie" style={{ width: '100px', height: '100px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(var(--accent-color-rgb), 0.3)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: '250px' }}>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, margin: '0 0 16px' }}>{speaking.bio}</p>
|
||||
{speaking.style && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{speaking.style.map((s, i) => (
|
||||
<span key={i} style={{ background: 'rgba(var(--accent-color-rgb), 0.1)', color: 'var(--accent-color)', padding: '6px 12px', borderRadius: 'var(--radius-pill)', fontSize: '0.8rem' }}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SpeakingAccordion sections={sections} talks={allTalks} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Format a date string to German locale.
|
||||
* @param {string} dateStr - ISO date string (e.g., "2025-01-15")
|
||||
* @param {object} options - Intl.DateTimeFormat options
|
||||
*/
|
||||
export function formatDate(dateStr, options = {}) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return dateStr
|
||||
|
||||
const defaults = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
return date.toLocaleDateString('de-DE', { ...defaults, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to short format (e.g., "Jan 2025").
|
||||
*/
|
||||
export function formatDateShort(dateStr) {
|
||||
return formatDate(dateStr, { year: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is in the future.
|
||||
*/
|
||||
export function isFuture(dateStr) {
|
||||
if (!dateStr) return false
|
||||
return new Date(dateStr) >= new Date()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function validateEmail(email) {
|
||||
return EMAIL_REGEX.test(email)
|
||||
}
|
||||
|
||||
export function validateContact(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 = 'Name darf maximal 100 Zeichen lang sein.'
|
||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||
if (!data.message || data.message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||
if (data.message && data.message.length > 2000) errors.message = 'Nachricht darf maximal 2000 Zeichen lang sein.'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
export function validateTalkRequest(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 = 'Name darf maximal 100 Zeichen lang sein.'
|
||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||
if (!data.event_name || data.event_name.trim().length === 0) errors.event_name = 'Eventname ist erforderlich.'
|
||||
if (data.event_name && data.event_name.length > 200) errors.event_name = 'Eventname darf maximal 200 Zeichen lang sein.'
|
||||
if (!data.topic || data.topic.trim().length === 0) errors.topic = 'Thema ist erforderlich.'
|
||||
if (data.topic && data.topic.length > 200) errors.topic = 'Thema darf maximal 200 Zeichen lang sein.'
|
||||
if (!data.message || data.message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||
if (data.message && data.message.length > 2000) errors.message = 'Nachricht darf maximal 2000 Zeichen lang sein.'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
export function validateNewsletter(data) {
|
||||
const errors = {}
|
||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
export function validateResourceDownload(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 = 'Name darf maximal 100 Zeichen lang sein.'
|
||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||
if (!data.company || data.company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||
if (data.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
|
||||
if (!data.resource_id) errors.resource_id = 'Resource-ID fehlt.'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
Reference in New Issue
Block a user