Compare commits
11
Commits
71804bb2cc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37bff0248d | ||
|
|
25602d9826 | ||
|
|
76540c4455 | ||
|
|
c4b5aa40ab | ||
|
|
88bd952a60 | ||
|
|
cf0fbd077e | ||
|
|
4ca2c1ece8 | ||
|
|
215aacf4a4 | ||
|
|
b507199987 | ||
|
|
0334252fed | ||
|
|
8236cce85c |
@@ -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
|
fi
|
||||||
|
}
|
||||||
|
trap rollback ERR
|
||||||
|
if [ -e site ]; then
|
||||||
|
rm -rf -- "$previous_dir"
|
||||||
|
mv -- site "$previous_dir"
|
||||||
|
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 -- {} +
|
||||||
|
|||||||
@@ -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>')
|
||||||
|
})
|
||||||
|
})
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 275 KiB |
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,6 +33,7 @@ export default function App() {
|
|||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<BackgroundPattern />
|
<BackgroundPattern />
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/artikel" element={<Articles />} />
|
<Route path="/artikel" element={<Articles />} />
|
||||||
@@ -45,6 +51,7 @@ export default function App() {
|
|||||||
<Route path="/bestaetigung" element={<Confirmation />} />
|
<Route path="/bestaetigung" element={<Confirmation />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</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 canonicalUrl = absoluteUrl(url || '/', '/')
|
||||||
|
const imageUrl = absoluteUrl(image, DEFAULT_IMAGE)
|
||||||
|
|
||||||
const siteTitle = title ? `${title} | Dr. André Knie` : defaultTitle
|
useEffect(() => {
|
||||||
const siteDesc = description || defaultDescription
|
document.head.querySelectorAll('[data-seo-fallback]').forEach(node => node.remove())
|
||||||
const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de"
|
}, [])
|
||||||
const siteImage = image || "https://andreknie.de/images/og-image.webp"
|
|
||||||
|
|
||||||
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} />
|
||||||
|
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||||
|
|
||||||
{/* Open Graph / Facebook */}
|
|
||||||
<meta property="og:type" content={type} />
|
<meta property="og:type" content={type} />
|
||||||
<meta property="og:url" content={siteUrl} />
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
<meta property="og:title" content={siteTitle} />
|
<meta property="og:title" content={siteTitle} />
|
||||||
<meta property="og:description" content={siteDesc} />
|
<meta property="og:description" content={siteDescription} />
|
||||||
<meta property="og:image" content={siteImage} />
|
<meta property="og:image" content={imageUrl} />
|
||||||
|
|
||||||
{/* Twitter */}
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta name="twitter:url" content={canonicalUrl} />
|
||||||
<meta property="twitter:url" content={siteUrl} />
|
<meta name="twitter:title" content={siteTitle} />
|
||||||
<meta property="twitter:title" content={siteTitle} />
|
<meta name="twitter:description" content={siteDescription} />
|
||||||
<meta property="twitter:description" content={siteDesc} />
|
<meta name="twitter:image" content={imageUrl} />
|
||||||
<meta property="twitter:image" content={siteImage} />
|
|
||||||
</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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1>
|
<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>
|
||||||
|
|||||||
@@ -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,10 +22,19 @@ 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">
|
||||||
<img src="/images/andre-knie.webp" alt="Dr. André Knie" className="hero-photo" />
|
<img
|
||||||
|
src="/images/andre-knie.webp?v=2"
|
||||||
|
alt="Dr. André Knie"
|
||||||
|
className="hero-photo"
|
||||||
|
width="280"
|
||||||
|
height="280"
|
||||||
|
decoding="async"
|
||||||
|
fetchPriority="high"
|
||||||
|
/>
|
||||||
<h1>
|
<h1>
|
||||||
{profile?.name || 'Dr. André Knie'}
|
{profile?.name || 'Dr. André Knie'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -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 (
|
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={{ 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 (
|
||||||
|
<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' }}>← Alle Ausgaben</Link>
|
<Link to="/kniepunkt" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '24px', display: 'inline-block' }}>← Alle Ausgaben</Link>
|
||||||
<h1 style={{ marginBottom: '12px' }}>{issue.title}</h1>
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
const selected = selectedResource === resourceId
|
||||||
|
return (
|
||||||
|
<div key={resourceId} className="glass-panel" style={{ padding: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||||
Download
|
{selected ? 'Schließen' : 'Material anfragen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
|
||||||
|
</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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user