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