Author SHA1 Message Date
ankn c4b5aa40ab fix(andreknie.de): close accessibility and performance review gaps 2026-07-29 00:05:18 +02:00
ankn 88bd952a60 feat(andreknie.de): improve accessibility and initial loading 2026-07-28 23:53:18 +02:00
ankn cf0fbd077e Merge PR #4: normalize SEO metadata and document structure
Reviewed and corrected. Verification: 47 tests passed; production build and source lint passed; all 15 routes have one title, description, canonical, main and h1; OG assets return HTTP 200.
2026-07-28 22:54:25 +02:00
ankn 4ca2c1ece8 fix(andreknie.de): close SEO review gaps 2026-07-28 22:53:37 +02:00
ankn 215aacf4a4 fix(andreknie.de): normalize SEO metadata and document structure 2026-07-28 21:42:54 +02:00
ankn b507199987 Merge PR #3: improve mobile dock and resource requests
Reviewed and corrected. Verification: 42 tests passed; production build and source lint passed; responsive browser checks passed at 375x844, 390x844, 430x932, and 1440x900.
2026-07-28 21:13:53 +02:00
ankn 0334252fed fix(andreknie.de): close package two review gaps 2026-07-28 21:13:21 +02:00
ankn 8236cce85c fix(andreknie.de): improve mobile dock and resource requests 2026-07-28 20:54:08 +02:00
ankn 71804bb2cc Merge PR #2: harden andreknie.de data lifecycle
Reviewed and verified: 35 tests, production build, source lint, credential scan, and responsive privacy-page checks passed. Includes updated Maike Mach role.
2026-07-28 20:46:22 +02:00
ankn 0459daa074 content(andreknie.de): update Maike Mach role 2026-07-28 20:42:53 +02:00
52 changed files with 1046 additions and 220 deletions
@@ -18,6 +18,10 @@ jobs:
run: | run: |
npm ci npm ci
npm run build npm run build
node scripts/check-bundle.mjs
test -s dist/index.html
test -d dist/assets
test -n "$(find dist/assets -type f -print -quit)"
- name: Copy project files to VM - name: Copy project files to VM
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
@@ -25,7 +29,7 @@ jobs:
host: ${{ secrets.DEPLOY_SSH_HOST }} host: ${{ secrets.DEPLOY_SSH_HOST }}
username: ${{ secrets.DEPLOY_SSH_USER }} username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "Caddyfile,*,dist/**,dist/.*" source: "Caddyfile,docker-compose.yml,Dockerfile,server/**,package.json,package-lock.json,dist/**,dist/.*"
target: "/opt/orchestrator/privat/CV/andreknie.de" target: "/opt/orchestrator/privat/CV/andreknie.de"
- name: Execute Deployment on VM - name: Execute Deployment on VM
@@ -35,6 +39,7 @@ jobs:
username: ${{ secrets.DEPLOY_SSH_USER }} username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | script: |
set -Eeuo pipefail
cd /opt/orchestrator/privat/CV/andreknie.de cd /opt/orchestrator/privat/CV/andreknie.de
# 1. Sichere .env aus den Gitea Secrets generieren # 1. Sichere .env aus den Gitea Secrets generieren
@@ -55,15 +60,37 @@ jobs:
HASH_SALT=${{ secrets.UMAMI_HASH_SALT }} HASH_SALT=${{ secrets.UMAMI_HASH_SALT }}
EOF EOF
# 2. Frontend-Build in den Caddy-Ordner verschieben # 2. Vollständiges Frontend als begrenztes Release vorbereiten.
mkdir -p site # Die Release-ID stammt aus dem Commit und bleibt rückverfolgbar.
# Wenn dist existiert, kopiere den Inhalt test -s dist/index.html
if [ -d "dist" ]; then test -d dist/assets
cp -r dist/* site/ || true release_id="${{ github.sha }}"
cp -r dist/.* site/ 2>/dev/null || true test -n "$release_id"
else case "$release_id" in
echo "FEHLER: dist Ordner existiert nicht!" (*[!A-Za-z0-9._-]*) echo "Ungültige Release-ID"; exit 1 ;;
esac
release_dir="releases/release-${release_id}"
previous_dir="releases/previous"
mkdir -p releases
if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi
mkdir -p "$release_dir"
cp -a dist/. "$release_dir/"
test -s "$release_dir/index.html"
test -n "$(find "$release_dir/assets" -type f -print -quit)"
rollback() {
if [ -d "$previous_dir" ]; then
rm -rf -- site
mv -- "$previous_dir" site
echo "Rollback auf das vorherige Release ausgeführt."
fi
}
trap rollback ERR
if [ -e site ]; then
rm -rf -- "$previous_dir"
mv -- site "$previous_dir"
fi fi
mv -- "$release_dir" site
# Debugging: Zeige an, ob die Dateien wirklich da sind # Debugging: Zeige an, ob die Dateien wirklich da sind
echo "INHALT VON SITE:" echo "INHALT VON SITE:"
@@ -77,3 +104,8 @@ jobs:
docker compose exec -T caddy grep -n "Content-Security-Policy" /etc/caddy/Caddyfile docker compose exec -T caddy grep -n "Content-Security-Policy" /etc/caddy/Caddyfile
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
docker compose up -d --force-recreate caddy docker compose up -d --force-recreate caddy
trap - ERR
# Abgebrochene temporäre Releases entfernen. Neben site bleibt
# höchstens ein klar benannter Rollback-Stand erhalten.
find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' -exec rm -rf -- {} +
@@ -1,6 +1,6 @@
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht." - quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
name: "Maike Mach" name: "Maike Mach"
role: "Stellvertretende Leitung Organisationsentwicklung, Universität Kassel" role: "Stellv. Leiterin Personal- und Organisationsentwicklung, Universität Kassel"
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten." - quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
name: "Kunde - Projektleitung Digitalisierung" name: "Kunde - Projektleitung Digitalisierung"
+13 -2
View File
@@ -4,8 +4,19 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logos/favicon.png" /> <link rel="icon" type="image/png" href="/logos/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dr. André Knie — KI, Transformation & Leadership</title> <title data-seo-fallback>Dr. André Knie Digitale Souveränität und KI</title>
<meta name="description" content="Nüchterne KI-Expertise für Mittelstand, Verwaltung und Hochschulen. Souverän, praktisch, evidenzbasiert." /> <meta data-seo-fallback name="description" content="Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren." />
<link data-seo-fallback rel="canonical" href="https://andreknie.de/" />
<meta data-seo-fallback property="og:type" content="website" />
<meta data-seo-fallback property="og:url" content="https://andreknie.de/" />
<meta data-seo-fallback property="og:title" content="Dr. André Knie Digitale Souveränität und KI" />
<meta data-seo-fallback property="og:description" content="Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren." />
<meta data-seo-fallback property="og:image" content="https://andreknie.de/images/andre-knie.webp" />
<meta data-seo-fallback name="twitter:card" content="summary_large_image" />
<meta data-seo-fallback name="twitter:url" content="https://andreknie.de/" />
<meta data-seo-fallback name="twitter:title" content="Dr. André Knie Digitale Souveränität und KI" />
<meta data-seo-fallback name="twitter:description" content="Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren." />
<meta data-seo-fallback name="twitter:image" content="https://andreknie.de/images/andre-knie.webp" />
<!-- Umami Analytics --> <!-- Umami Analytics -->
<script <script
defer defer
@@ -7,8 +7,13 @@ import * as yaml from 'js-yaml'
const CONTENT_DIR = resolve(process.cwd(), 'content') const CONTENT_DIR = resolve(process.cwd(), 'content')
const VIRTUAL_MODULE_ID = 'virtual:content' const VIRTUAL_MODULE_ID = 'virtual:content'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
const DETAIL_LOADERS_MODULE_ID = 'virtual:content-detail-loaders'
const RESOLVED_DETAIL_LOADERS_MODULE_ID = '\0' + DETAIL_LOADERS_MODULE_ID
const DETAIL_MODULE_PREFIX = 'virtual:content-detail/'
const RESOLVED_DETAIL_MODULE_PREFIX = '\0' + DETAIL_MODULE_PREFIX
const DETAIL_TYPES = ['posts', 'kniepunkt']
function readContentDir(subdir) { function readContentDir(subdir, includeBodies = false) {
const dir = join(CONTENT_DIR, subdir) const dir = join(CONTENT_DIR, subdir)
if (!existsSync(dir)) return [] if (!existsSync(dir)) return []
@@ -22,10 +27,12 @@ function readContentDir(subdir) {
if (ext === '.md') { if (ext === '.md') {
const raw = readFileSync(filepath, 'utf-8') const raw = readFileSync(filepath, 'utf-8')
const { data, content } = matter(raw) const { data, content } = matter(raw)
const body = renderMarkdownContent(content)
const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 320)
return { return {
...data, ...data,
slug, slug,
body: marked(content), ...(includeBodies ? { body } : { searchText }),
_source: `${subdir}/${filename}` _source: `${subdir}/${filename}`
} }
} }
@@ -41,6 +48,18 @@ function readContentDir(subdir) {
.filter(Boolean) .filter(Boolean)
} }
export function renderMarkdownContent(content) {
const tokens = marked.lexer(content)
const firstContentToken = tokens.findIndex(token => token.type !== 'space')
const firstToken = tokens[firstContentToken]
if (firstToken?.type === 'heading' && firstToken.depth === 1) {
tokens.splice(firstContentToken, 1)
}
return marked.parser(tokens)
}
function readMetaFile(filename) { function readMetaFile(filename) {
const filepath = join(CONTENT_DIR, 'meta', filename) const filepath = join(CONTENT_DIR, 'meta', filename)
if (!existsSync(filepath)) return null if (!existsSync(filepath)) return null
@@ -48,15 +67,15 @@ function readMetaFile(filename) {
return yaml.load(raw) return yaml.load(raw)
} }
function loadAllContent() { function loadAllContent(includeBodies = false) {
return { return {
posts: readContentDir('posts'), posts: readContentDir('posts', includeBodies),
kniepunkt: readContentDir('kniepunkt'), kniepunkt: readContentDir('kniepunkt', includeBodies),
podcast: readContentDir('podcast'), podcast: readContentDir('podcast', includeBodies),
talks: readContentDir('talks'), talks: readContentDir('talks', includeBodies),
consulting: readContentDir('consulting'), consulting: readContentDir('consulting', includeBodies),
resources: readContentDir('resources'), resources: readContentDir('resources', includeBodies),
events: readContentDir('events'), events: readContentDir('events', includeBodies),
meta: { meta: {
profile: readMetaFile('profile.yaml'), profile: readMetaFile('profile.yaml'),
stats: readMetaFile('stats.yaml'), stats: readMetaFile('stats.yaml'),
@@ -70,25 +89,57 @@ function loadAllContent() {
} }
} }
function createDetailLoadersModule() {
const typeEntries = DETAIL_TYPES.map(type => {
const loaders = readContentDir(type)
.map(item => `${JSON.stringify(item.slug)}: () => import(${JSON.stringify(`${DETAIL_MODULE_PREFIX}${type}/${item.slug}`)})`)
.join(',\n')
return `${JSON.stringify(type)}: {\n${loaders}\n}`
})
return `export default {\n${typeEntries.join(',\n')}\n}`
}
function loadDetailModule(id) {
const relativeId = id.slice(RESOLVED_DETAIL_MODULE_PREFIX.length)
const separator = relativeId.indexOf('/')
if (separator < 1) return null
const type = relativeId.slice(0, separator)
const slug = relativeId.slice(separator + 1)
if (!DETAIL_TYPES.includes(type)) return null
const item = readContentDir(type, true).find(candidate => candidate.slug === slug)
return item ? `export default ${JSON.stringify(item)}` : null
}
export default function contentPlugin() { export default function contentPlugin() {
return { return {
name: 'vite-plugin-content', name: 'vite-plugin-content',
resolveId(id) { resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
if (id === DETAIL_LOADERS_MODULE_ID) return RESOLVED_DETAIL_LOADERS_MODULE_ID
if (id.startsWith(DETAIL_MODULE_PREFIX)) return `\0${id}`
}, },
load(id) { load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) { if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const content = loadAllContent() const content = loadAllContent()
return `export default ${JSON.stringify(content)}` return `export default ${JSON.stringify(content)}`
} }
if (id === RESOLVED_DETAIL_LOADERS_MODULE_ID) return createDetailLoadersModule()
if (id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX)) return loadDetailModule(id)
}, },
handleHotUpdate({ file, server }) { handleHotUpdate({ file, server }) {
if (file.includes('content')) { if (file.includes('content')) {
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID) const modules = [
if (mod) { server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID),
server.moduleGraph.invalidateModule(mod) server.moduleGraph.getModuleById(RESOLVED_DETAIL_LOADERS_MODULE_ID),
return [mod] ...Array.from(server.moduleGraph.idToModuleMap.entries())
} .filter(([id]) => id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX))
.map(([, module]) => module),
].filter(Boolean)
modules.forEach(module => server.moduleGraph.invalidateModule(module))
return modules
} }
} }
} }
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdownContent } from './vite-plugin-content.js'
describe('markdown content rendering', () => {
it('removes only an initial level-one heading', () => {
const html = renderMarkdownContent('# Duplicate title\n\nIntro\n\n## Section')
expect(html).not.toContain('<h1>Duplicate title</h1>')
expect(html).toContain('<p>Intro</p>')
expect(html).toContain('<h2>Section</h2>')
})
it('keeps content when no initial level-one heading exists', () => {
const html = renderMarkdownContent('Intro\n\n# Deliberate heading')
expect(html).toContain('<p>Intro</p>')
expect(html).toContain('<h1>Deliberate heading</h1>')
})
})
@@ -0,0 +1,27 @@
import { readdirSync, statSync } from 'node:fs'
import { basename, join } from 'node:path'
const assetsDir = join(process.cwd(), 'dist', 'assets')
const files = readdirSync(assetsDir)
const entry = files.find(file => /^index-[^/]+\.js$/.test(file))
const detailSlugs = ['posts', 'kniepunkt'].flatMap(type =>
readdirSync(join(process.cwd(), 'content', type))
.filter(file => file.endsWith('.md'))
.map(file => basename(file, '.md')),
)
const missingDetailChunks = detailSlugs.filter(
slug => !files.some(file => file.startsWith(`${slug}-`) && file.endsWith('.js')),
)
const maxInitialBytes = 400 * 1024
if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.')
if (missingDetailChunks.length > 0) {
throw new Error(`Fehlende Content-Detail-Chunks: ${missingDetailChunks.join(', ')}`)
}
const entryBytes = statSync(join(assetsDir, entry)).size
if (entryBytes > maxInitialBytes) {
throw new Error(`Initialer JS-Chunk ist zu groß: ${entryBytes} Bytes (Limit ${maxInitialBytes}).`)
}
console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunks=${detailSlugs.length}`)
@@ -96,4 +96,37 @@ describe('backend mail safety', () => {
expect(await reserveToken(token)).toMatchObject({ status: 'processing' }) expect(await reserveToken(token)).toMatchObject({ status: 'processing' })
await releaseToken(token) await releaseToken(token)
}) })
it('rejects unknown resources before any notification is sent', async () => {
const response = await fetch(`${baseUrl}/api/resource-download`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Test',
email: 'test@example.invalid',
company: 'Synthetic Test',
resource_id: 'does-not-exist',
}),
})
expect(response.status).toBe(400)
expect((await response.json()).errors.resource_id).toBeTruthy()
})
it('silently accepts resource honeypot submissions', async () => {
const response = await fetch(`${baseUrl}/api/resource-download`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Test',
email: 'test@example.invalid',
company: 'Synthetic Test',
resource_id: 'does-not-exist',
company_website: 'https://bot.example.invalid',
}),
})
expect(response.status).toBe(201)
expect((await response.json()).ok).toBe(true)
})
}) })
@@ -1,27 +1,19 @@
import { Router } from 'express' import { Router } from 'express'
import { readFileSync, writeFileSync, existsSync } from 'fs' import { existsSync, readdirSync } from 'fs'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { Mutex } from 'async-mutex'
import { resourceLimiter } from '../middleware/rateLimiter.js' import { resourceLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js' import { antiSpam } from '../middleware/antiSpam.js'
import { sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json') const RESOURCES_DIR = join(__dirname, '..', '..', 'public', 'resources')
const leadsMutex = new Mutex()
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path export function getAvailableResourceIds() {
// traversal (e.g. "../../etc/passwd") when building the download URL. if (!existsSync(RESOURCES_DIR)) return []
const RESOURCE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/ return readdirSync(RESOURCES_DIR, { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.pdf') && entry.name !== 'speaker-cv-andre-knie.pdf')
function loadLeads() { .map(entry => entry.name.slice(0, -4))
if (!existsSync(LEADS_FILE)) return []
try { return JSON.parse(readFileSync(LEADS_FILE, 'utf-8')) }
catch { return [] }
}
function saveLeads(leads) {
writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2), 'utf-8')
} }
const router = Router() const router = Router()
@@ -34,26 +26,18 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.' if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.' if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.' if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.'
if (!resource_id || !RESOURCE_ID_PATTERN.test(resource_id)) errors.resource_id = 'Ungültige Resource-ID.' if (!resource_id || !getAvailableResourceIds().includes(resource_id)) {
errors.resource_id = 'Diese Ressource ist derzeit nicht verfügbar.'
if (Object.keys(errors).length > 0) {
return res.status(400).json({ errors })
} }
await leadsMutex.runExclusive(async () => { if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
const leads = loadLeads()
leads.push({
name,
email,
company,
resource_id,
timestamp: new Date().toISOString(),
})
saveLeads(leads)
})
// Return download URL (resource files are in public/resources/) try {
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` }) await sendStakeholderNotification('resource-download', { name, email, company, resource_id })
res.status(202).json({ ok: true, message: 'Danke! Wir prüfen die Anfrage und melden uns bei dir.' })
} catch (error) {
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht übermittelt werden.' })
}
}) })
export default router export default router
@@ -59,20 +59,30 @@ export async function sendStakeholderNotification(type, data) {
const subjects = { const subjects = {
contact: `Neue Kontaktanfrage von ${data.name}`, contact: `Neue Kontaktanfrage von ${data.name}`,
'talk-request': `Neue Vortragsanfrage von ${data.name}`, 'talk-request': `Neue Vortragsanfrage von ${data.name}`,
'resource-download': `Ressourcenanfrage von ${data.name}`,
} }
const body = Object.entries(data) const body = buildStakeholderMessage(type, data)
.map(([key, val]) => `${key}: ${val}`)
.join('\n')
await t.sendMail({ await t.sendMail({
from: SMTP_USER, from: SMTP_USER,
to: STAKEHOLDER_EMAIL, to: type === 'resource-download' ? 'kontakt@d-hive.de' : STAKEHOLDER_EMAIL,
subject: subjects[type] || `Neue Anfrage (${type})`, subject: subjects[type] || `Neue Anfrage (${type})`,
text: body, text: body,
}) })
} }
export function buildStakeholderMessage(type, data) {
const fields = Object.entries(data).map(([key, val]) => `${key}: ${val}`)
if (type !== 'resource-download') return fields.join('\n')
return [
'Bitte die angefragten Inhalte prüfen und bei positiver Prüfung ein aktuelles Profil von André verschicken.',
'',
...fields,
].join('\n')
}
export { STAKEHOLDER_EMAIL, BASE_URL } export { STAKEHOLDER_EMAIL, BASE_URL }
/** /**
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js' import {
buildStakeholderMessage,
isMailerReady,
MailerNotReadyError,
sendConfirmationEmail,
} from './mailer.js'
describe('mailer readiness', () => { describe('mailer readiness', () => {
it('reports missing SMTP configuration without exposing credentials', async () => { it('reports missing SMTP configuration without exposing credentials', async () => {
@@ -10,3 +15,24 @@ describe('mailer readiness', () => {
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' }) .rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
}) })
}) })
describe('stakeholder notification content', () => {
const data = {
name: 'Synthetic Test',
email: 'test@example.invalid',
}
it('adds the manual profile instruction to resource requests', () => {
const message = buildStakeholderMessage('resource-download', data)
expect(message).toContain('ein aktuelles Profil von André verschicken')
expect(message).toContain('name: Synthetic Test')
})
it('does not add the resource instruction to contact notifications', () => {
const message = buildStakeholderMessage('contact', data)
expect(message).not.toContain('ein aktuelles Profil von André verschicken')
expect(message).toBe('name: Synthetic Test\nemail: test@example.invalid')
})
})
+40 -33
View File
@@ -1,25 +1,30 @@
import React from 'react' import React, { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import Navigation from './components/Navigation' import Navigation from './components/Navigation'
import Footer from './components/Footer' import Footer from './components/Footer'
import BackgroundPattern from './components/BackgroundPattern' import BackgroundPattern from './components/BackgroundPattern'
import BottomDock from './components/BottomDock' import BottomDock from './components/BottomDock'
import ScrollToTop from './components/ScrollToTop' import ScrollToTop from './components/ScrollToTop'
import Home from './pages/Home'
import Articles from './pages/Articles' const Home = lazy(() => import('./pages/Home'))
import ArticleSingle from './pages/ArticleSingle' const Articles = lazy(() => import('./pages/Articles'))
import Kniepunkt from './pages/Kniepunkt' const ArticleSingle = lazy(() => import('./pages/ArticleSingle'))
import KniepunktIssue from './pages/KniepunktIssue' const Kniepunkt = lazy(() => import('./pages/Kniepunkt'))
import Podcast from './pages/Podcast' const KniepunktIssue = lazy(() => import('./pages/KniepunktIssue'))
import Speaking from './pages/Speaking' const Podcast = lazy(() => import('./pages/Podcast'))
import Consulting from './pages/Consulting' const Speaking = lazy(() => import('./pages/Speaking'))
import Resources from './pages/Resources' const Consulting = lazy(() => import('./pages/Consulting'))
import Contact from './pages/Contact' const Resources = lazy(() => import('./pages/Resources'))
import Impressum from './pages/Impressum' const Contact = lazy(() => import('./pages/Contact'))
import Datenschutz from './pages/Datenschutz' const Impressum = lazy(() => import('./pages/Impressum'))
import About from './pages/About' const Datenschutz = lazy(() => import('./pages/Datenschutz'))
import NotFound from './pages/NotFound' const About = lazy(() => import('./pages/About'))
import Confirmation from './pages/Confirmation' const NotFound = lazy(() => import('./pages/NotFound'))
const Confirmation = lazy(() => import('./pages/Confirmation'))
function PageFallback() {
return <div className="page-loading" role="status" aria-live="polite">Inhalt wird geladen </div>
}
export default function App() { export default function App() {
return ( return (
@@ -28,23 +33,25 @@ export default function App() {
<div className="app-root"> <div className="app-root">
<BackgroundPattern /> <BackgroundPattern />
<Navigation /> <Navigation />
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/artikel" element={<Articles />} /> <Route path="/" element={<Home />} />
<Route path="/artikel/:slug" element={<ArticleSingle />} /> <Route path="/artikel" element={<Articles />} />
<Route path="/kniepunkt" element={<Kniepunkt />} /> <Route path="/artikel/:slug" element={<ArticleSingle />} />
<Route path="/kniepunkt/:slug" element={<KniepunktIssue />} /> <Route path="/kniepunkt" element={<Kniepunkt />} />
<Route path="/podcast" element={<Podcast />} /> <Route path="/kniepunkt/:slug" element={<KniepunktIssue />} />
<Route path="/speaking" element={<Speaking />} /> <Route path="/podcast" element={<Podcast />} />
<Route path="/consulting" element={<Consulting />} /> <Route path="/speaking" element={<Speaking />} />
<Route path="/resources" element={<Resources />} /> <Route path="/consulting" element={<Consulting />} />
<Route path="/kontakt" element={<Contact />} /> <Route path="/resources" element={<Resources />} />
<Route path="/ueber-mich" element={<About />} /> <Route path="/kontakt" element={<Contact />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/ueber-mich" element={<About />} />
<Route path="/datenschutz" element={<Datenschutz />} /> <Route path="/impressum" element={<Impressum />} />
<Route path="/bestaetigung" element={<Confirmation />} /> <Route path="/datenschutz" element={<Datenschutz />} />
<Route path="*" element={<NotFound />} /> <Route path="/bestaetigung" element={<Confirmation />} />
</Routes> <Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
<Footer /> <Footer />
<BottomDock /> <BottomDock />
</div> </div>
@@ -0,0 +1,65 @@
import React from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BackgroundPattern from './BackgroundPattern'
import DotCloudIcon from './DotCloudIcon'
const context = {
arc: vi.fn(),
beginPath: vi.fn(),
clearRect: vi.fn(),
closePath: vi.fn(),
drawImage: vi.fn(),
ellipse: vi.fn(),
fill: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(140 * 140 * 4) })),
lineTo: vi.fn(),
moveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
scale: vi.fn(),
setTransform: vi.fn(),
}
function setReducedMotion(matches) {
vi.stubGlobal('matchMedia', vi.fn(() => ({
matches,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})))
}
describe('canvas animation lifecycle', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context)
Object.defineProperty(document, 'hidden', { configurable: true, value: false })
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 17))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('does not schedule background animation with reduced motion', () => {
setReducedMotion(true)
render(<BackgroundPattern />)
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
it('cancels a scheduled background frame on unmount', () => {
setReducedMotion(false)
const { unmount } = render(<BackgroundPattern />)
expect(requestAnimationFrame).toHaveBeenCalled()
unmount()
expect(cancelAnimationFrame).toHaveBeenCalledWith(17)
})
it('does not schedule dot animation with reduced motion', async () => {
setReducedMotion(true)
render(<DotCloudIcon shape="shield" />)
await act(async () => Promise.resolve())
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
})
@@ -15,12 +15,14 @@ export default function BackgroundPattern() {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
function resize() { function resize() {
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
canvas.width = window.innerWidth * dpr canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr canvas.height = window.innerHeight * dpr
ctx.scale(dpr, dpr) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
canvas.style.width = window.innerWidth + 'px' canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px' canvas.style.height = window.innerHeight + 'px'
initParticles() initParticles()
@@ -61,8 +63,6 @@ export default function BackgroundPattern() {
const h = window.innerHeight const h = window.innerHeight
ctx.clearRect(0, 0, w, h) ctx.clearRect(0, 0, w, h)
const style = getComputedStyle(document.documentElement)
const accentColor = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current const mouse = mouseRef.current
const repelRadius = 80 const repelRadius = 80
@@ -99,14 +99,21 @@ export default function BackgroundPattern() {
ctx.globalAlpha = 1 ctx.globalAlpha = 1
}) })
animRef.current = requestAnimationFrame(animate) if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
}
function handleVisibilityChange() {
if (document.hidden) cancelAnimationFrame(animRef.current)
else if (!reducedMotion) animate()
} }
animate() animate()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => { return () => {
cancelAnimationFrame(animRef.current) cancelAnimationFrame(animRef.current)
window.removeEventListener('resize', resize) window.removeEventListener('resize', resize)
window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, []) }, [])
@@ -76,6 +76,11 @@
transform: translateY(-8px) scale(1.15); transform: translateY(-8px) scale(1.15);
} }
.dock-item:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 3px;
}
.dock-item.active { .dock-item.active {
color: var(--accent-color); color: var(--accent-color);
background: rgba(var(--accent-color-rgb), 0.1); background: rgba(var(--accent-color-rgb), 0.1);
@@ -114,6 +119,23 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.dock { gap: 6px; padding: 6px 8px; } .dock-container {
.dock-item { width: 38px; height: 38px; } bottom: max(12px, env(safe-area-inset-bottom));
max-width: calc(100vw - 24px);
}
.dock {
gap: 6px;
padding: 6px 8px;
max-width: 100%;
}
.dock-item-wrapper-secondary {
display: none;
}
.dock-item {
width: 44px;
height: 44px;
}
} }
@@ -10,22 +10,29 @@ export default function BottomDock() {
{ label: 'Home', path: '/', icon: <Home size={20} /> }, { label: 'Home', path: '/', icon: <Home size={20} /> },
{ label: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> }, { label: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> },
{ label: 'Kniepunkt', path: '/kniepunkt', icon: <span style={{ fontWeight: 800, fontSize: '1.2rem', fontFamily: 'serif' }}>K</span> }, { label: 'Kniepunkt', path: '/kniepunkt', icon: <span style={{ fontWeight: 800, fontSize: '1.2rem', fontFamily: 'serif' }}>K</span> },
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} /> }, { label: 'Podcast', path: '/podcast', icon: <Mic size={20} />, secondary: true },
{ label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> }, { label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> },
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> }, { label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} />, secondary: true },
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> }, { label: 'Über mich', path: '/ueber-mich', icon: <User size={20} />, secondary: true },
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> }, { label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
] ]
return ( return (
<div className="dock-container"> <div className="dock-container">
<nav className="dock"> <nav className="dock" aria-label="Schnellnavigation">
{items.map((item) => { {items.map((item) => {
const isActive = location.pathname === item.path const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)
return ( return (
<div className="dock-item-wrapper" key={item.path}> <div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}>
<span className="dock-tooltip">{item.label}</span> <span className="dock-tooltip">{item.label}</span>
<Link to={item.path} className={`dock-item ${isActive ? 'active' : ''}`}> <Link
to={item.path}
className={`dock-item ${isActive ? 'active' : ''}`}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
{item.icon} {item.icon}
</Link> </Link>
</div> </div>
@@ -0,0 +1,30 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { readFileSync } from 'fs'
import { join } from 'path'
import BottomDock from './BottomDock'
describe('BottomDock', () => {
it('keeps secondary links available on desktop and marks them for mobile hiding', () => {
const { container } = render(
<MemoryRouter initialEntries={['/']}>
<BottomDock />
</MemoryRouter>,
)
expect(container.querySelectorAll('.dock-item')).toHaveLength(8)
expect(container.querySelectorAll('.dock-item-wrapper-secondary')).toHaveLength(3)
expect(container.querySelectorAll('.dock-item[aria-label]')).toHaveLength(8)
})
it('reserves mobile space and keeps touch targets at least 44 pixels', () => {
const css = readFileSync(join(process.cwd(), 'src', 'components', 'BottomDock.css'), 'utf-8')
const mobileRule = css.match(/@media \(max-width: 600px\) \{([\s\S]*)\}\s*$/)
expect(mobileRule?.[1]).toContain('dock-item-wrapper-secondary')
expect(mobileRule?.[1]).toMatch(/width: 44px/)
expect(mobileRule?.[1]).toMatch(/height: 44px/)
})
})
@@ -20,6 +20,9 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
let active = true
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr canvas.width = size * dpr
canvas.height = size * dpr canvas.height = size * dpr
@@ -27,6 +30,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
async function init() { async function init() {
const points = await getShapePoints(shape, size) const points = await getShapePoints(shape, size)
if (!active) return
particlesRef.current = points.map(p => ({ particlesRef.current = points.map(p => ({
x: p.x, y: p.y, x: p.x, y: p.y,
baseX: p.x, baseY: p.y, baseX: p.x, baseY: p.y,
@@ -34,6 +38,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
vx: 0, vy: 0, vx: 0, vy: 0,
})) }))
initializedRef.current = true initializedRef.current = true
animate()
} }
function handleMouseMove(e) { function handleMouseMove(e) {
@@ -48,16 +53,15 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
canvas.addEventListener('mousemove', handleMouseMove) canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave) canvas.addEventListener('mouseleave', handleMouseLeave)
document.addEventListener('visibilitychange', handleVisibilityChange)
function animate() { function animate() {
ctx.clearRect(0, 0, size, size) ctx.clearRect(0, 0, size, size)
if (!initializedRef.current) { if (!initializedRef.current) {
animRef.current = requestAnimationFrame(animate) if (!reducedMotion) animRef.current = requestAnimationFrame(animate)
return return
} }
const style = getComputedStyle(document.documentElement)
const color = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
const mouse = mouseRef.current const mouse = mouseRef.current
const repelRadius = 25 const repelRadius = 25
@@ -98,16 +102,22 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
ctx.fill() ctx.fill()
}) })
ctx.globalAlpha = 1 ctx.globalAlpha = 1
animRef.current = requestAnimationFrame(animate) if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
}
function handleVisibilityChange() {
if (document.hidden) cancelAnimationFrame(animRef.current)
else if (!reducedMotion) animate()
} }
init() init()
animate()
return () => { return () => {
active = false
cancelAnimationFrame(animRef.current) cancelAnimationFrame(animRef.current)
canvas.removeEventListener('mousemove', handleMouseMove) canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave) canvas.removeEventListener('mouseleave', handleMouseLeave)
document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, [shape, size]) }, [shape, size])
@@ -177,10 +187,9 @@ function sampleGermany(size) {
} }
} }
console.log('[DotCloudIcon] Germany points:', points.length)
resolve(points) resolve(points)
} }
img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) } img.onerror = () => resolve([])
img.src = '/images/germany-outline.png' img.src = '/images/germany-outline.png'
}) })
} }
@@ -26,6 +26,11 @@
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
} }
.marquee-track:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
}
.marquee-inner { .marquee-inner {
display: flex; display: flex;
gap: 60px; gap: 60px;
@@ -34,6 +39,11 @@
width: max-content; width: max-content;
} }
.marquee-section:hover .marquee-inner,
.marquee-section:focus-within .marquee-inner {
animation-play-state: paused;
}
.marquee-item { .marquee-item {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -18,10 +18,10 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>} {title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
</h2> </h2>
)} )}
<div className="marquee-track"> <div className="marquee-track" role="region" aria-label="Kunden- und Partnerlogos" tabIndex="0">
<div className="marquee-inner"> <div className="marquee-inner">
{allLogos.map((logo, i) => ( {allLogos.map((logo, i) => (
<div className="marquee-item" key={i}> <div className="marquee-item" key={i} aria-hidden={i >= logos.length}>
{logo.src ? ( {logo.src ? (
<img src={logo.src} alt={logo.name} /> <img src={logo.src} alt={logo.name} />
) : ( ) : (
@@ -0,0 +1,13 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import LogoMarquee from './LogoMarquee'
describe('LogoMarquee accessibility', () => {
it('hides loop copies from assistive technology', () => {
const { container } = render(<LogoMarquee logos={[{ name: 'Logo A' }, { name: 'Logo B' }]} />)
expect(container.querySelectorAll('.marquee-item:not([aria-hidden="true"])')).toHaveLength(2)
expect(container.querySelectorAll('.marquee-item[aria-hidden="true"]')).toHaveLength(4)
expect(container.querySelector('.marquee-track').tabIndex).toBe(0)
})
})
@@ -75,6 +75,14 @@
padding: 8px; padding: 8px;
} }
.hamburger:focus-visible,
.nav-link:focus-visible,
.nav-mobile-link:focus-visible,
.logo:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 4px;
}
.hamburger span { .hamburger span {
display: block; display: block;
width: 24px; width: 24px;
@@ -13,7 +13,15 @@ export default function Navigation() {
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll)
}, []) }, [])
// Close the mobile menu whenever navigation occurs. useEffect(() => {
function handleKeyDown(event) {
if (event.key === 'Escape') setMobileOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
function closeMenu() { function closeMenu() {
setMobileOpen(false) setMobileOpen(false)
} }
@@ -28,8 +36,10 @@ export default function Navigation() {
{ to: '/kontakt', label: 'Kontakt' }, { to: '/kontakt', label: 'Kontakt' },
] ]
const isActive = (path) => location.pathname === path || location.pathname.startsWith(`${path}/`)
return ( return (
<nav className={`navigation ${scrolled ? 'scrolled' : ''}`}> <nav className={`navigation ${scrolled ? 'scrolled' : ''}`} aria-label="Hauptnavigation">
<div className="nav-container app-container"> <div className="nav-container app-container">
<Link to="/" className="logo" onClick={closeMenu}> <Link to="/" className="logo" onClick={closeMenu}>
<span className="logo-text">Dr. André Knie</span> <span className="logo-text">Dr. André Knie</span>
@@ -40,18 +50,21 @@ export default function Navigation() {
<Link <Link
key={link.to} key={link.to}
to={link.to} to={link.to}
className={`nav-link ${location.pathname === link.to ? 'active' : ''}`} className={`nav-link ${isActive(link.to) ? 'active' : ''}`}
aria-current={isActive(link.to) ? 'page' : undefined}
> >
{link.label} {link.label}
</Link> </Link>
))} ))}
</div> </div>
{/* Mobile hamburger (only visible on small screens) */}
<button <button
className={`hamburger ${mobileOpen ? 'active' : ''}`} className={`hamburger ${mobileOpen ? 'active' : ''}`}
onClick={() => setMobileOpen(!mobileOpen)} onClick={() => setMobileOpen(open => !open)}
aria-label="Menü öffnen" aria-label={mobileOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileOpen}
aria-controls="mobile-navigation"
type="button"
> >
<span></span> <span></span>
<span></span> <span></span>
@@ -59,14 +72,14 @@ export default function Navigation() {
</button> </button>
</div> </div>
{/* Mobile dropdown */} <div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`} hidden={!mobileOpen}>
<div className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
{navLinks.map(link => ( {navLinks.map(link => (
<Link <Link
key={link.to} key={link.to}
to={link.to} to={link.to}
className={`nav-mobile-link ${location.pathname === link.to ? 'active' : ''}`} className={`nav-mobile-link ${isActive(link.to) ? 'active' : ''}`}
onClick={closeMenu} onClick={closeMenu}
aria-current={isActive(link.to) ? 'page' : undefined}
> >
{link.label} {link.label}
</Link> </Link>
@@ -0,0 +1,29 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import Navigation from './Navigation'
describe('Navigation accessibility', () => {
it('exposes menu state and closes it with Escape', () => {
render(<MemoryRouter><Navigation /></MemoryRouter>)
const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
const mobileNavigation = document.getElementById('mobile-navigation')
expect(mobileNavigation.hidden).toBe(true)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
expect(mobileNavigation.hidden).toBe(false)
fireEvent.keyDown(document, { key: 'Escape' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
expect(mobileNavigation.hidden).toBe(true)
})
it('marks detail navigation routes as current', () => {
render(<MemoryRouter initialEntries={['/artikel/beispiel']}><Navigation /></MemoryRouter>)
expect(screen.getAllByRole('link', { name: 'Artikel' }).every(link => link.getAttribute('aria-current') === 'page')).toBe(true)
})
})
@@ -40,7 +40,7 @@ export default function NewsletterSignup({ newsletter }) {
{newsletter.email_signup && ( {newsletter.email_signup && (
<form className="newsletter-email" onSubmit={handleSubmit}> <form className="newsletter-email" onSubmit={handleSubmit}>
{/* Honeypot */} {/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={(e) => setForm(p => ({ ...p, company_website: e.target.value }))} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={(e) => setForm(p => ({ ...p, company_website: e.target.value }))} />
@@ -48,7 +48,7 @@ export default function NewsletterSignup({ newsletter }) {
</div> </div>
{success ? ( {success ? (
<p className="newsletter-success">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p> <p className="newsletter-success" role="status" aria-live="polite">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p>
) : ( ) : (
<> <>
<div className="newsletter-input-row"> <div className="newsletter-input-row">
@@ -60,13 +60,15 @@ export default function NewsletterSignup({ newsletter }) {
value={form.email} value={form.email}
onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))}
aria-label="E-Mail-Adresse" aria-label="E-Mail-Adresse"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'newsletter-email-error' : undefined}
/> />
<button type="submit" className="secondary-button" disabled={loading}> <button type="submit" className="secondary-button" disabled={loading}>
{loading ? '...' : 'Abonnieren'} {loading ? '...' : 'Abonnieren'}
</button> </button>
</div> </div>
{errors.email && <p className="newsletter-error">{errors.email}</p>} {errors.email && <p id="newsletter-email-error" className="newsletter-error" role="alert">{errors.email}</p>}
{serverError && <p className="newsletter-error">{serverError}</p>} {serverError && <p className="newsletter-error" role="alert" aria-live="assertive">{serverError}</p>}
</> </>
)} )}
</form> </form>
@@ -1,33 +1,36 @@
import React from 'react' import React, { useEffect } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { absoluteUrl, BRAND_SUFFIX, DEFAULT_DESCRIPTION, DEFAULT_IMAGE, DEFAULT_TITLE, normalizeTitle } from './seo.js'
export default function SEOHead({ title, description, url, image, type = 'website' }) { export default function SEOHead({ title, description, url, image, type = 'website', noindex = false }) {
const defaultTitle = "Dr. André Knie - Experte für Digitale Souveränität" const pageTitle = normalizeTitle(title)
const defaultDescription = "Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren." const siteTitle = pageTitle === DEFAULT_TITLE ? pageTitle : `${pageTitle}${BRAND_SUFFIX}`
const siteDescription = description || DEFAULT_DESCRIPTION
const siteTitle = title ? `${title} | Dr. André Knie` : defaultTitle const canonicalUrl = absoluteUrl(url || '/', '/')
const siteDesc = description || defaultDescription const imageUrl = absoluteUrl(image, DEFAULT_IMAGE)
const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de"
const siteImage = image || "https://andreknie.de/images/og-image.webp" useEffect(() => {
document.head.querySelectorAll('[data-seo-fallback]').forEach(node => node.remove())
}, [])
return ( return (
<Helmet> <Helmet>
<title>{siteTitle}</title> <title>{siteTitle}</title>
<meta name="description" content={siteDesc} /> <meta name="description" content={siteDescription} />
<link rel="canonical" href={canonicalUrl} />
{/* Open Graph / Facebook */} {noindex && <meta name="robots" content="noindex,follow" />}
<meta property="og:type" content={type} />
<meta property="og:url" content={siteUrl} />
<meta property="og:title" content={siteTitle} />
<meta property="og:description" content={siteDesc} />
<meta property="og:image" content={siteImage} />
{/* Twitter */} <meta property="og:type" content={type} />
<meta property="twitter:card" content="summary_large_image" /> <meta property="og:url" content={canonicalUrl} />
<meta property="twitter:url" content={siteUrl} /> <meta property="og:title" content={siteTitle} />
<meta property="twitter:title" content={siteTitle} /> <meta property="og:description" content={siteDescription} />
<meta property="twitter:description" content={siteDesc} /> <meta property="og:image" content={imageUrl} />
<meta property="twitter:image" content={siteImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonicalUrl} />
<meta name="twitter:title" content={siteTitle} />
<meta name="twitter:description" content={siteDescription} />
<meta name="twitter:image" content={imageUrl} />
</Helmet> </Helmet>
) )
} }
@@ -0,0 +1,62 @@
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import SEOHead from './SEOHead'
import { BRAND_SUFFIX } from './seo.js'
afterEach(() => {
cleanup()
document.head.querySelectorAll('[data-rh="true"]').forEach(node => node.remove())
})
describe('SEOHead', () => {
it('renders one branded title, description, and canonical link', () => {
render(
<HelmetProvider>
<SEOHead title={`Artikel${BRAND_SUFFIX}`} description="Synthetic description" url="/artikel" />
</HelmetProvider>,
)
expect(document.head.querySelectorAll('title')).toHaveLength(1)
expect(document.title).toBe(`Artikel${BRAND_SUFFIX}`)
expect(document.title.match(/Dr\. André Knie/g)).toHaveLength(1)
expect(document.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://andreknie.de/artikel')
})
it('replaces static fallback metadata instead of duplicating it', () => {
document.head.insertAdjacentHTML('beforeend', `
<title data-seo-fallback>Fallback</title>
<meta data-seo-fallback name="description" content="Fallback description">
<link data-seo-fallback rel="canonical" href="https://andreknie.de/">
`)
render(
<HelmetProvider>
<SEOHead title="Artikel" description="Article description" url="/artikel" />
</HelmetProvider>,
)
expect(document.head.querySelectorAll('[data-seo-fallback]')).toHaveLength(0)
expect(document.head.querySelectorAll('title')).toHaveLength(1)
expect(document.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1)
})
it('normalizes relative and absolute image URLs', () => {
const { rerender } = render(
<HelmetProvider>
<SEOHead title="Test" image="/images/andre-knie.webp" />
</HelmetProvider>,
)
expect(document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe('https://andreknie.de/images/andre-knie.webp')
rerender(
<HelmetProvider>
<SEOHead title="Test" image="https://cdn.example.invalid/image.webp" />
</HelmetProvider>,
)
expect(document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe('https://cdn.example.invalid/image.webp')
})
})
@@ -43,7 +43,7 @@ export default function SpeakerCvRequest() {
return ( return (
<div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}> <div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}>
{success ? ( {success ? (
<p style={{ margin: 0, color: 'var(--text-primary)' }}> <p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Der Speaker CV ist auf dem Weg in dein Postfach. Danke! Der Speaker CV ist auf dem Weg in dein Postfach.
</p> </p>
) : ( ) : (
@@ -53,7 +53,7 @@ export default function SpeakerCvRequest() {
</p> </p>
{/* Honeypot */} {/* Honeypot */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
@@ -61,24 +61,24 @@ export default function SpeakerCvRequest() {
</div> </div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label> <label htmlFor="speaker-name" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
<input name="name" value={form.name} onChange={handleChange} style={inputStyle(errors.name)} /> <input id="speaker-name" name="name" value={form.name} onChange={handleChange} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'speaker-name-error' : undefined} style={inputStyle(errors.name)} />
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>} {errors.name && <p id="speaker-name-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
</div> </div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label> <label htmlFor="speaker-email" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
<input name="email" type="email" value={form.email} onChange={handleChange} style={inputStyle(errors.email)} /> <input id="speaker-email" name="email" type="email" value={form.email} onChange={handleChange} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'speaker-email-error' : undefined} style={inputStyle(errors.email)} />
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>} {errors.email && <p id="speaker-email-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label> <label htmlFor="speaker-message" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label>
<textarea name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} style={{ ...inputStyle(errors.message), resize: 'vertical' }} /> <textarea id="speaker-message" name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} aria-invalid={Boolean(errors.message)} aria-describedby={errors.message ? 'speaker-message-error' : undefined} style={{ ...inputStyle(errors.message), resize: 'vertical' }} />
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>} {errors.message && <p id="speaker-message-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
</div> </div>
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>
<button type="submit" className="primary-button" disabled={loading}> <button type="submit" className="primary-button" disabled={loading}>
@@ -43,6 +43,13 @@
100% { transform: rotateY(-360deg); } 100% { transform: rotateY(-360deg); }
} }
@media (prefers-reduced-motion: reduce) {
.cube-container {
animation: none;
transform: rotateY(0deg);
}
}
.cube-stat-value { .cube-stat-value {
font-size: 3.5rem; font-size: 3.5rem;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -45,6 +45,12 @@
-ms-overflow-style: none; /* IE/Edge */ -ms-overflow-style: none; /* IE/Edge */
} }
.testimonial-scroll-container:focus-visible,
.testimonial-nav-button:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 4px;
}
.testimonial-scroll-container.dragging { .testimonial-scroll-container.dragging {
cursor: grabbing; cursor: grabbing;
scroll-snap-type: none; scroll-snap-type: none;
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from 'react' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css' import './TestimonialSlider.css'
@@ -12,7 +12,9 @@ const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
export default function TestimonialSlider({ testimonials = [] }) { export default function TestimonialSlider({ testimonials = [] }) {
const scrollerRef = useRef(null) const scrollerRef = useRef(null)
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 }) const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const recenterTimerRef = useRef(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const canLoop = testimonials.length > 1 const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop const visibleTestimonials = canLoop
? Array.from({ length: LOOP_SETS }, () => testimonials).flat() ? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
@@ -46,10 +48,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
const centerCard = (cardIndex, behavior = 'auto') => { const centerCard = (cardIndex, behavior = 'auto') => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
scroller.scrollTo({ scroller.scrollTo({
left: getCenteredScrollLeft(cardIndex), left: getCenteredScrollLeft(cardIndex),
behavior, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : behavior,
}) })
} }
@@ -67,6 +71,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
return () => window.cancelAnimationFrame(frameId) return () => window.cancelAnimationFrame(frameId)
}, [canLoop, testimonials.length]) }, [canLoop, testimonials.length])
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
const scrollByCard = (direction) => { const scrollByCard = (direction) => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
@@ -74,7 +80,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
const targetIndex = getClosestCardIndex() + direction const targetIndex = getClosestCardIndex() + direction
centerCard(targetIndex, 'smooth') centerCard(targetIndex, 'smooth')
window.setTimeout(recenterIfNeeded, 450) window.clearTimeout(recenterTimerRef.current)
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
} }
const recenterIfNeeded = () => { const recenterIfNeeded = () => {
@@ -82,11 +89,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
if (!canLoop || !scroller) return if (!canLoop || !scroller) return
const currentIndex = getClosestCardIndex() const currentIndex = getClosestCardIndex()
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
const lowBoundary = testimonials.length const lowBoundary = testimonials.length
const highBoundary = testimonials.length * (LOOP_SETS - 1) const highBoundary = testimonials.length * (LOOP_SETS - 1)
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex) centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
} }
@@ -123,10 +131,21 @@ export default function TestimonialSlider({ testimonials = [] }) {
} }
} }
const handleKeyDown = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollByCard(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollByCard(1)
}
}
if (testimonials.length === 0) return null if (testimonials.length === 0) return null
return ( return (
<section className="testimonials-section app-container"> <section className="testimonials-section app-container" aria-labelledby="testimonials-heading">
<h2 id="testimonials-heading" className="sr-only">Testimonials</h2>
<div className="testimonial-slider-controls" aria-label="Testimonials wechseln"> <div className="testimonial-slider-controls" aria-label="Testimonials wechseln">
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial"> <button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial">
<ChevronLeft size={22} aria-hidden="true" /> <ChevronLeft size={22} aria-hidden="true" />
@@ -135,9 +154,17 @@ export default function TestimonialSlider({ testimonials = [] }) {
<ChevronRight size={22} aria-hidden="true" /> <ChevronRight size={22} aria-hidden="true" />
</button> </button>
</div> </div>
<p className="sr-only" role="status" aria-live="polite">
Testimonial {activeIndex + 1} von {testimonials.length}
</p>
<div <div
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`} className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
ref={scrollerRef} ref={scrollerRef}
role="region"
aria-label={`Testimonial ${testimonials.length > 1 ? 'Karussell' : ''}`}
aria-roledescription="Karussell"
tabIndex="0"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={stopDragging} onPointerUp={stopDragging}
@@ -147,7 +174,7 @@ export default function TestimonialSlider({ testimonials = [] }) {
}} }}
> >
{visibleTestimonials.map((current, i) => ( {visibleTestimonials.map((current, i) => (
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`}> <div className="testimonial-card glass-panel" key={`${current.name}-${i}`} aria-hidden={canLoop && Math.floor(i / testimonials.length) !== MIDDLE_SET}>
<div className="testimonial-quote"> <div className="testimonial-quote">
<span className="quote-mark">"</span> <span className="quote-mark">"</span>
<p>{current.quote}</p> <p>{current.quote}</p>
@@ -0,0 +1,17 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import TestimonialSlider from './TestimonialSlider'
describe('TestimonialSlider accessibility', () => {
it('exposes only one logical testimonial set', () => {
const testimonials = [
{ name: 'Person A', quote: 'Zitat A', role: 'Rolle A' },
{ name: 'Person B', quote: 'Zitat B', role: 'Rolle B' },
{ name: 'Person C', quote: 'Zitat C', role: 'Rolle C' },
]
const { container } = render(<TestimonialSlider testimonials={testimonials} />)
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3')
})
})
@@ -0,0 +1,16 @@
export const SITE_URL = 'https://andreknie.de'
export const BRAND_SUFFIX = ' | Dr. André Knie'
export const DEFAULT_TITLE = 'Dr. André Knie Digitale Souveränität und KI'
export const DEFAULT_DESCRIPTION = 'Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren.'
export const DEFAULT_IMAGE = '/images/andre-knie.webp'
export function absoluteUrl(value, fallback) {
const candidate = value || fallback
if (/^https?:\/\//i.test(candidate)) return candidate
return new URL(candidate.startsWith('/') ? candidate : `/${candidate}`, SITE_URL).href
}
export function normalizeTitle(title) {
if (!title) return DEFAULT_TITLE
return title.endsWith(BRAND_SUFFIX) ? title.slice(0, -BRAND_SUFFIX.length) : title
}
+34 -2
View File
@@ -1,4 +1,8 @@
import { useEffect, useState } from 'react'
import content from 'virtual:content' import content from 'virtual:content'
import detailLoaders from 'virtual:content-detail-loaders'
const detailCache = new Map()
/** /**
* Get all content of a given type, sorted by date descending. * Get all content of a given type, sorted by date descending.
@@ -50,8 +54,36 @@ export function useContent(type, options = {}) {
* Get a single content item by slug. * Get a single content item by slug.
*/ */
export function useContentItem(type, slug) { export function useContentItem(type, slug) {
const items = content[type] || [] const metadata = (content[type] || []).find(item => item.slug === slug) || null
return items.find(item => item.slug === slug) || null const cacheKey = `${type}:${slug}`
const [detailState, setDetailState] = useState(() => ({
key: cacheKey,
item: detailCache.get(cacheKey) || metadata,
}))
useEffect(() => {
let active = true
if (!metadata || detailCache.has(cacheKey)) return () => { active = false }
const loadDetail = detailLoaders[type]?.[slug]
const detailPromise = loadDetail
? loadDetail()
: Promise.reject(new Error(`No detail loader for ${cacheKey}`))
detailPromise
.then(module => {
const detail = module.default
detailCache.set(cacheKey, detail)
if (active) setDetailState({ key: cacheKey, item: detail })
})
.catch(() => {
if (active) setDetailState({ key: cacheKey, item: { ...metadata, detailError: true } })
})
return () => { active = false }
}, [cacheKey, metadata, slug, type])
return detailState.key === cacheKey ? detailState.item : metadata
} }
/** /**
+48
View File
@@ -42,6 +42,9 @@
--radius-md: 16px; --radius-md: 16px;
--radius-lg: 24px; --radius-lg: 24px;
--radius-pill: 50px; --radius-pill: 50px;
/* Space reserved for the fixed mobile navigation dock. */
--dock-content-space: 0px;
} }
/* ========================================================================== /* ==========================================================================
@@ -65,6 +68,13 @@ body {
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden;
padding-bottom: var(--dock-content-space);
}
@media (max-width: 600px) {
:root {
--dock-content-space: calc(88px + env(safe-area-inset-bottom));
}
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@@ -153,6 +163,34 @@ img {
color: var(--accent-color); color: var(--accent-color);
} }
.page-loading {
min-height: 50vh;
display: grid;
place-items: center;
padding: 120px 24px 80px;
color: var(--text-secondary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 3px;
}
/* Section layout */ /* Section layout */
.section-header { .section-header {
text-align: center; text-align: center;
@@ -194,3 +232,13 @@ img {
0% { opacity: 0; } 0% { opacity: 0; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}
@@ -2,6 +2,7 @@ import React from 'react'
import { useMeta } from '../hooks/useContent' import { useMeta } from '../hooks/useContent'
import { ExternalLink, Globe } from 'lucide-react' import { ExternalLink, Globe } from 'lucide-react'
import SpeakerCvRequest from '../components/SpeakerCvRequest' import SpeakerCvRequest from '../components/SpeakerCvRequest'
import SEOHead from '../components/SEOHead'
export default function About() { export default function About() {
const { profile, about } = useMeta() const { profile, about } = useMeta()
@@ -12,6 +13,7 @@ export default function About() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Über mich" description="Über Dr. André Knie, seine Arbeit, Stationen und Perspektive auf KI und digitale Souveränität." url="/ueber-mich" />
<div className="app-container" style={{ maxWidth: '800px' }}> <div className="app-container" style={{ maxWidth: '800px' }}>
<div className="section-header"> <div className="section-header">
<h1 className="section-title">Über <span className="text-gradient">mich</span></h1> <h1 className="section-title">Über <span className="text-gradient">mich</span></h1>
@@ -1,20 +1,37 @@
import React from 'react' import React from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useContent } from '../hooks/useContent' import { useContentItem } from '../hooks/useContent'
import SEOHead from '../components/SEOHead' import SEOHead from '../components/SEOHead'
import { decodeHtml } from '../utils/decodeHtml' import { decodeHtml } from '../utils/decodeHtml'
export default function ArticleSingle() { export default function ArticleSingle() {
const { slug } = useParams() const { slug } = useParams()
const { items: posts } = useContent('posts', { pageSize: 1000 }) const post = useContentItem('posts', slug)
const post = posts.find((p) => p.slug === slug)
if (!post) { if (!post) {
return ( return (
<div className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}> <main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
<h2>Artikel nicht gefunden</h2> <SEOHead
title="Artikel nicht gefunden"
description="Der angeforderte Artikel wurde nicht gefunden."
url={`/artikel/${slug}`}
noindex
/>
<h1>Artikel nicht gefunden</h1>
<Link to="/artikel" className="primary-button" style={{ marginTop: '20px' }}>Zurück zur Übersicht</Link> <Link to="/artikel" className="primary-button" style={{ marginTop: '20px' }}>Zurück zur Übersicht</Link>
</div> </main>
)
}
if (!post.body) {
const loadFailed = post.detailError
return (
<main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
<SEOHead title={post.title} description={post.excerpt || post.title} url={`/artikel/${post.slug}`} type="article" />
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Der Artikel konnte nicht geladen werden.' : 'Artikel wird geladen …'}
</p>
</main>
) )
} }
@@ -23,9 +40,11 @@ export default function ArticleSingle() {
: '' : ''
return ( return (
<div className="page-container article-single-page"> <main className="page-container article-single-page">
<SEOHead <SEOHead
title={`${post.title} | Dr. André Knie`} url={`/artikel/${post.slug}`}
type="article"
title={post.title}
description={post.excerpt || post.title} description={post.excerpt || post.title}
/> />
<div className="app-container" style={{ maxWidth: '800px', margin: '0 auto', paddingTop: '120px' }}> <div className="app-container" style={{ maxWidth: '800px', margin: '0 auto', paddingTop: '120px' }}>
@@ -48,6 +67,6 @@ export default function ArticleSingle() {
/> />
</article> </article>
</div> </div>
</div> </main>
) )
} }
@@ -15,14 +15,15 @@ export default function Articles() {
return ( return (
(post.title && post.title.toLowerCase().includes(query)) || (post.title && post.title.toLowerCase().includes(query)) ||
(post.excerpt && post.excerpt.toLowerCase().includes(query)) || (post.excerpt && post.excerpt.toLowerCase().includes(query)) ||
(post.body && post.body.toLowerCase().includes(query)) (post.searchText && post.searchText.toLowerCase().includes(query))
) )
}) })
return ( return (
<div className="page-container articles-page"> <main className="page-container articles-page">
<SEOHead <SEOHead
title="Artikel & Beiträge | Dr. André Knie" url="/artikel"
title="Artikel & Beiträge"
description="Aktuelle Artikel und LinkedIn-Beiträge zu den Themen Softwareentwicklung, Leadership und Technologie." description="Aktuelle Artikel und LinkedIn-Beiträge zu den Themen Softwareentwicklung, Leadership und Technologie."
/> />
<header className="page-header"> <header className="page-header">
@@ -44,6 +45,7 @@ export default function Articles() {
<input <input
type="text" type="text"
placeholder="Artikel durchsuchen..." placeholder="Artikel durchsuchen..."
aria-label="Artikel durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -83,7 +85,7 @@ export default function Articles() {
)} )}
<h2 className="article-title">{decodeHtml(post.title)}</h2> <h2 className="article-title">{decodeHtml(post.title)}</h2>
<p className="article-excerpt"> <p className="article-excerpt">
{decodeHtml(post.excerpt || (post.body ? post.body.replace(/<[^>]+>/g, '').substring(0, 150) + '...' : ''))} {decodeHtml(post.excerpt || (post.searchText ? post.searchText.substring(0, 150) + '...' : ''))}
</p> </p>
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
<a href={`/artikel/${post.slug}`} className="article-link"> <a href={`/artikel/${post.slug}`} className="article-link">
@@ -102,6 +104,6 @@ export default function Articles() {
<p className="no-articles">Keine Artikel für diese Suche gefunden.</p> <p className="no-articles">Keine Artikel für diese Suche gefunden.</p>
)} )}
</section> </section>
</div> </main>
) )
} }
@@ -13,6 +13,8 @@ export default function Confirmation() {
<SEOHead <SEOHead
title={isSuccess ? "Vielen Dank" : "Ein Fehler ist aufgetreten"} title={isSuccess ? "Vielen Dank" : "Ein Fehler ist aufgetreten"}
description={isSuccess ? "Ihre Nachricht wurde erfolgreich gesendet." : "Es gab ein Problem beim Senden Ihrer Nachricht."} description={isSuccess ? "Ihre Nachricht wurde erfolgreich gesendet." : "Es gab ein Problem beim Senden Ihrer Nachricht."}
url="/bestaetigung"
noindex
/> />
<main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}> <div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { useContent, useMeta } from '../hooks/useContent' import { useContent, useMeta } from '../hooks/useContent'
import ServiceAccordion from '../components/ServiceAccordion' import ServiceAccordion from '../components/ServiceAccordion'
import SEOHead from '../components/SEOHead'
export default function Consulting() { export default function Consulting() {
const { items } = useContent('consulting', { pageSize: 10 }) const { items } = useContent('consulting', { pageSize: 10 })
@@ -8,6 +9,7 @@ export default function Consulting() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Beratung" description="Beratung zu KI-Strategie, digitaler Souveränität und praktischer Transformation." url="/consulting" />
<div className="app-container"> <div className="app-container">
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Leistungen</span></h1> <h1 className="section-title"><span className="text-gradient">Leistungen</span></h1>
+17 -12
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useFormSubmit } from '../hooks/useFormSubmit' import { useFormSubmit } from '../hooks/useFormSubmit'
import { validateContact } from '../utils/validation' import { validateContact } from '../utils/validation'
import SEOHead from '../components/SEOHead'
export default function Contact() { export default function Contact() {
const [form, setForm] = useState({ name: '', email: '', message: '', company_website: '' }) const [form, setForm] = useState({ name: '', email: '', message: '', company_website: '' })
@@ -18,8 +19,11 @@ export default function Contact() {
if (success) { if (success) {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Kontakt" description="Nimm Kontakt zu Dr. André Knie auf." url="/kontakt" />
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}> <div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1> <div role="status" aria-live="polite">
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1>
</div>
<p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p> <p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p>
</div> </div>
</main> </main>
@@ -28,6 +32,7 @@ export default function Contact() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Kontakt" description="Nimm Kontakt zu Dr. André Knie auf." url="/kontakt" />
<div className="app-container" style={{ maxWidth: '600px' }}> <div className="app-container" style={{ maxWidth: '600px' }}>
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Kontakt</span></h1> <h1 className="section-title"><span className="text-gradient">Kontakt</span></h1>
@@ -36,7 +41,7 @@ export default function Contact() {
<form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '40px' }}> <form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '40px' }}>
{/* Honeypot — hidden from real users, only bots fill this. */} {/* Honeypot — hidden from real users, only bots fill this. */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label> <label>
Firmen-Website (bitte leer lassen) Firmen-Website (bitte leer lassen)
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} /> <input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
@@ -44,25 +49,25 @@ export default function Contact() {
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label> <label htmlFor="contact-name" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
<input name="name" value={form.name} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.name ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} /> <input id="contact-name" name="name" value={form.name} onChange={handleChange} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'contact-name-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.name ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>} {errors.name && <p id="contact-name-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label> <label htmlFor="contact-email" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
<input name="email" type="email" value={form.email} onChange={handleChange} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.email ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} /> <input id="contact-email" name="email" type="email" value={form.email} onChange={handleChange} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'contact-email-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.email ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem' }} />
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>} {errors.email && <p id="contact-email-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
</div> </div>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Nachricht</label> <label htmlFor="contact-message" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Nachricht</label>
<textarea name="message" value={form.message} onChange={handleChange} rows={6} maxLength={2000} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.message ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem', resize: 'vertical' }} /> <textarea id="contact-message" name="message" value={form.message} onChange={handleChange} rows={6} maxLength={2000} aria-invalid={Boolean(errors.message)} aria-describedby={errors.message ? 'contact-message-error' : undefined} style={{ width: '100%', padding: '12px', background: 'var(--bg-elevated)', border: errors.message ? '1px solid #ff4444' : '1px solid var(--glass-border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '1rem', resize: 'vertical' }} />
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>} {errors.message && <p id="contact-message-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
<p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', margin: '4px 0 0' }}>{form.message.length}/2000</p> <p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem', margin: '4px 0 0' }}>{form.message.length}/2000</p>
</div> </div>
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
<button type="submit" className="primary-button" disabled={loading} style={{ width: '100%' }}> <button type="submit" className="primary-button" disabled={loading} style={{ width: '100%' }}>
{loading ? 'Wird gesendet...' : 'Nachricht senden'} {loading ? 'Wird gesendet...' : 'Nachricht senden'}
@@ -1,8 +1,10 @@
import React from 'react' import React from 'react'
import SEOHead from '../components/SEOHead'
export default function Datenschutz() { export default function Datenschutz() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Datenschutz" description="Datenschutzerklärung von andreknie.de." url="/datenschutz" />
<div className="app-container" style={{ maxWidth: '700px' }}> <div className="app-container" style={{ maxWidth: '700px' }}>
<h1 style={{ marginBottom: '32px' }}>Datenschutzerklärung</h1> <h1 style={{ marginBottom: '32px' }}>Datenschutzerklärung</h1>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}> <div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
@@ -24,8 +26,8 @@ export default function Datenschutz() {
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p> <p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis Newsletter abbestellen veranlassen.</p> <p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis Newsletter abbestellen veranlassen.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</strong></p> <p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcenanfragen</strong></p>
<p>Für einen Ressourcen-Download werden Name, E-Mail-Adresse, Unternehmen, angeforderte Ressource und Zeitpunkt gespeichert. Die Daten dienen der Bearbeitung der Download-Anfrage und der Beziehungspflege. Sie werden gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p> <p>Für eine Ressourcenanfrage werden Name, E-Mail-Adresse, Unternehmen und die angeforderte Ressource ausschließlich an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> übermittelt. Es erfolgt kein automatischer Download und kein automatischer Versand. Die Anfrage dient der Prüfung, ob André ein aktuelles Profil verschickt. Eine lokale Lead-Datei wird dabei nicht angelegt.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p> <p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p> <p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
+40
View File
@@ -12,6 +12,46 @@
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 600px) {
.hero {
padding-top: 100px;
padding-bottom: calc(60px + var(--dock-content-space));
}
.hero-photo {
width: 140px;
height: 140px;
margin-bottom: 16px;
}
.hero h1 {
font-size: 2.25rem;
margin-bottom: 8px;
}
.hero-roles {
font-size: 1rem;
margin-bottom: 12px;
}
.hero-subtext {
font-size: 1rem;
line-height: 1.45;
margin-bottom: 24px;
}
.hero-actions {
flex-wrap: nowrap;
gap: 10px;
}
.hero-actions .primary-button,
.hero-actions .secondary-button {
padding: 12px 18px;
font-size: 0.95rem;
}
}
.hero-content { .hero-content {
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -6,6 +6,7 @@ import UpcomingEvents from '../components/UpcomingEvents'
import StatsCube from '../components/StatsCube' import StatsCube from '../components/StatsCube'
import DotCloudIcon from '../components/DotCloudIcon' import DotCloudIcon from '../components/DotCloudIcon'
import PhotoBand from '../components/PhotoBand' import PhotoBand from '../components/PhotoBand'
import SEOHead from '../components/SEOHead'
import './Home.css' import './Home.css'
const PHOTO_BAND_IMAGES = [ const PHOTO_BAND_IMAGES = [
@@ -21,6 +22,7 @@ export default function Home() {
return ( return (
<main> <main>
<SEOHead url="/" />
{/* Hero */} {/* Hero */}
<section className="hero"> <section className="hero">
<div className="app-container hero-content"> <div className="app-container hero-content">
@@ -1,8 +1,10 @@
import React from 'react' import React from 'react'
import SEOHead from '../components/SEOHead'
export default function Impressum() { export default function Impressum() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Impressum" description="Impressum von andreknie.de." url="/impressum" />
<div className="app-container" style={{ maxWidth: '700px' }}> <div className="app-container" style={{ maxWidth: '700px' }}>
<h1 style={{ marginBottom: '32px' }}>Impressum</h1> <h1 style={{ marginBottom: '32px' }}>Impressum</h1>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}> <div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
@@ -4,6 +4,7 @@ import { Search } from 'lucide-react'
import { useContent } from '../hooks/useContent' import { useContent } from '../hooks/useContent'
import { formatDate } from '../utils/formatDate' import { formatDate } from '../utils/formatDate'
import { decodeHtml } from '../utils/decodeHtml' import { decodeHtml } from '../utils/decodeHtml'
import SEOHead from '../components/SEOHead'
export default function Kniepunkt() { export default function Kniepunkt() {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -15,12 +16,13 @@ export default function Kniepunkt() {
return ( return (
(item.title && item.title.toLowerCase().includes(query)) || (item.title && item.title.toLowerCase().includes(query)) ||
(item.summary && item.summary.toLowerCase().includes(query)) || (item.summary && item.summary.toLowerCase().includes(query)) ||
(item.body && item.body.toLowerCase().includes(query)) (item.searchText && item.searchText.toLowerCase().includes(query))
) )
}) })
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Kniepunkt" description="Die Kniepunkt-Kolumne von Dr. André Knie über KI, Technologie und die Absurditäten des digitalen Wandels." url="/kniepunkt" />
<div className="app-container"> <div className="app-container">
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Kniepunkt</span></h1> <h1 className="section-title"><span className="text-gradient">Kniepunkt</span></h1>
@@ -34,6 +36,7 @@ export default function Kniepunkt() {
<input <input
type="text" type="text"
placeholder="Kniepunkte durchsuchen..." placeholder="Kniepunkte durchsuchen..."
aria-label="Kniepunkte durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -2,6 +2,7 @@ import React from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useContentItem } from '../hooks/useContent' import { useContentItem } from '../hooks/useContent'
import { formatDate } from '../utils/formatDate' import { formatDate } from '../utils/formatDate'
import SEOHead from '../components/SEOHead'
export default function KniepunktIssue() { export default function KniepunktIssue() {
const { slug } = useParams() const { slug } = useParams()
@@ -10,6 +11,7 @@ export default function KniepunktIssue() {
if (!issue) { if (!issue) {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Kniepunkt-Ausgabe nicht gefunden" description="Die angeforderte Kniepunkt-Ausgabe wurde nicht gefunden." noindex url={`/kniepunkt/${slug}`} />
<div className="app-container" style={{ textAlign: 'center' }}> <div className="app-container" style={{ textAlign: 'center' }}>
<h1>Ausgabe nicht gefunden</h1> <h1>Ausgabe nicht gefunden</h1>
<p style={{ color: 'var(--text-secondary)' }}>Diese Kniepunkt-Ausgabe existiert nicht.</p> <p style={{ color: 'var(--text-secondary)' }}>Diese Kniepunkt-Ausgabe existiert nicht.</p>
@@ -19,8 +21,23 @@ export default function KniepunktIssue() {
) )
} }
if (!issue.body) {
const loadFailed = issue.detailError
return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
<div className="app-container" style={{ textAlign: 'center' }}>
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Die Kniepunkt-Ausgabe konnte nicht geladen werden.' : 'Kniepunkt-Ausgabe wird geladen …'}
</p>
</div>
</main>
)
}
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
<div className="app-container" style={{ maxWidth: '700px' }}> <div className="app-container" style={{ maxWidth: '700px' }}>
<Link to="/kniepunkt" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '24px', display: 'inline-block' }}>&larr; Alle Ausgaben</Link> <Link to="/kniepunkt" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '24px', display: 'inline-block' }}>&larr; Alle Ausgaben</Link>
<h1 style={{ marginBottom: '12px' }}>{issue.title}</h1> <h1 style={{ marginBottom: '12px' }}>{issue.title}</h1>
@@ -1,9 +1,11 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import SEOHead from '../components/SEOHead'
export default function NotFound() { export default function NotFound() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SEOHead title="Seite nicht gefunden" description="Die angeforderte Seite wurde nicht gefunden." noindex url="/404" />
<div className="app-container" style={{ textAlign: 'center' }}> <div className="app-container" style={{ textAlign: 'center' }}>
<h1 style={{ fontSize: '6rem', marginBottom: '16px' }}><span className="text-gradient">404</span></h1> <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> <p style={{ color: 'var(--text-secondary)', fontSize: '1.2rem', marginBottom: '32px' }}>Diese Seite existiert nicht.</p>
@@ -2,6 +2,7 @@ import React from 'react'
import { useContent } from '../hooks/useContent' import { useContent } from '../hooks/useContent'
import { formatDate } from '../utils/formatDate' import { formatDate } from '../utils/formatDate'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import SEOHead from '../components/SEOHead'
export default function Podcast() { export default function Podcast() {
const { items: allEpisodes } = useContent('podcast', { visibility: 'all', pageSize: 100 }) const { items: allEpisodes } = useContent('podcast', { visibility: 'all', pageSize: 100 })
@@ -13,6 +14,7 @@ export default function Podcast() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Podcast" description="Podcasts, Gastauftritte und Gespräche von und mit Dr. André Knie über KI, Transformation und Leadership." url="/podcast" />
<div className="app-container" style={{ maxWidth: '800px', margin: '0 auto' }}> <div className="app-container" style={{ maxWidth: '800px', margin: '0 auto' }}>
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Podcast</span></h1> <h1 className="section-title"><span className="text-gradient">Podcast</span></h1>
+95 -12
View File
@@ -1,29 +1,112 @@
import React from 'react' import React, { useState } from 'react'
import { useContent } from '../hooks/useContent' import { useContent } from '../hooks/useContent'
import { useFormSubmit } from '../hooks/useFormSubmit'
import { validateResourceDownload } from '../utils/validation'
import SEOHead from '../components/SEOHead'
const inputStyle = {
width: '100%',
padding: '12px',
background: 'var(--bg-elevated)',
border: '1px solid var(--glass-border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontSize: '1rem',
}
function ResourceRequestForm({ resource, onCancel }) {
const resourceId = resource.id || resource.slug
const [form, setForm] = useState({ name: '', email: '', company: '', resource_id: resourceId, company_website: '' })
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/resource-download', validateResourceDownload)
function updateField(event) {
setForm(prev => ({ ...prev, [event.target.name]: event.target.value }))
}
async function handleSubmit(event) {
event.preventDefault()
await submit(form)
}
return (
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
{success ? (
<p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Wir prüfen die Anfrage und melden uns bei dir.
</p>
) : (
<form onSubmit={handleSubmit} noValidate>
<h2 style={{ fontSize: '1.25rem', marginBottom: '8px' }}>Material anfragen</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: 0 }}>
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
</p>
<div hidden>
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
</div>
<input type="hidden" name="resource_id" value={resourceId} />
<label htmlFor="resource-name">Name</label>
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'resource-name-error' : undefined} />
{errors.name && <p id="resource-name-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
<label htmlFor="resource-email">E-Mail</label>
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'resource-email-error' : undefined} />
{errors.email && <p id="resource-email-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
<label htmlFor="resource-company">Unternehmen</label>
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} aria-describedby={errors.company ? 'resource-company-error' : undefined} />
{errors.company && <p id="resource-company-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
{serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444' }}>{serverError}</p>}
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>
<button type="submit" className="primary-button" disabled={loading}>
{loading ? 'Wird gesendet...' : 'Anfrage senden'}
</button>
<button type="button" className="secondary-button" onClick={onCancel}>Abbrechen</button>
</div>
</form>
)}
</div>
)
}
export default function Resources() { export default function Resources() {
const { items } = useContent('resources', { pageSize: 20 }) const { items } = useContent('resources', { pageSize: 20 })
const [selectedResource, setSelectedResource] = useState(null)
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Ressourcen" description="Ausgewählte Materialien und Profile von Dr. André Knie auf Anfrage." url="/resources" />
<div className="app-container"> <div className="app-container">
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1> <h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1>
<p className="section-subtitle">Praktische Materialien zum Download.</p> <p className="section-subtitle">Materialien auf Anfrage. Jede Anfrage wird zunächst geprüft.</p>
</div> </div>
<div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}> <div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
{items.map(res => ( {items.map(resource => {
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}> const resourceId = resource.id || resource.slug
<div> const selected = selectedResource === resourceId
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3> return (
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p> <div key={resourceId} className="glass-panel" style={{ padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
<div>
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
</div>
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
{selected ? 'Schließen' : 'Material anfragen'}
</button>
</div>
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
</div> </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>} {items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { useContent, useMeta } from '../hooks/useContent' import { useContent, useMeta } from '../hooks/useContent'
import SpeakingAccordion from '../components/SpeakingAccordion' import SpeakingAccordion from '../components/SpeakingAccordion'
import SEOHead from '../components/SEOHead'
export default function Speaking() { export default function Speaking() {
const { items: allTalks } = useContent('talks', { visibility: 'all', pageSize: 100 }) const { items: allTalks } = useContent('talks', { visibility: 'all', pageSize: 100 })
@@ -9,6 +10,7 @@ export default function Speaking() {
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title="Speaking" description="Keynotes, Workshops und Panels von Dr. André Knie zu KI, Transformation und Leadership." url="/speaking" />
<div className="app-container"> <div className="app-container">
<div className="section-header"> <div className="section-header">
<h1 className="section-title"><span className="text-gradient">Speaking</span></h1> <h1 className="section-title"><span className="text-gradient">Speaking</span></h1>
@@ -41,6 +41,6 @@ export function validateResourceDownload(data) {
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.' 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.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.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
if (!data.resource_id) errors.resource_id = 'Resource-ID fehlt.' if (!data.resource_id || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(data.resource_id)) errors.resource_id = 'Resource-ID fehlt oder ist ungültig.'
return { valid: Object.keys(errors).length === 0, errors } return { valid: Object.keys(errors).length === 0, errors }
} }
@@ -125,4 +125,10 @@ describe('validateResourceDownload', () => {
expect(result.valid).toBe(false) expect(result.valid).toBe(false)
expect(result.errors.resource_id).toBeTruthy() expect(result.errors.resource_id).toBeTruthy()
}) })
it('fails for a resource id that could escape the resource naming scheme', () => {
const result = validateResourceDownload({ ...valid, resource_id: '../profile' })
expect(result.valid).toBe(false)
expect(result.errors.resource_id).toBeTruthy()
})
}) })