Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca2c1ece8 | ||
|
|
215aacf4a4 | ||
|
|
b507199987 | ||
|
|
0334252fed | ||
|
|
8236cce85c | ||
|
|
71804bb2cc | ||
|
|
0459daa074 |
@@ -1,6 +1,6 @@
|
||||
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
|
||||
name: "Maike Mach"
|
||||
role: "Stellvertretende Leitung Organisationsentwicklung, Universität Kassel"
|
||||
role: "Stellv. Leiterin Personal- und Organisationsentwicklung, Universität Kassel"
|
||||
|
||||
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
|
||||
name: "Kunde - Projektleitung Digitalisierung"
|
||||
|
||||
@@ -4,8 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logos/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dr. André Knie — KI, Transformation & Leadership</title>
|
||||
<meta name="description" content="Nüchterne KI-Expertise für Mittelstand, Verwaltung und Hochschulen. Souverän, praktisch, evidenzbasiert." />
|
||||
<title data-seo-fallback>Dr. André Knie – Digitale Souveränität und KI</title>
|
||||
<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 -->
|
||||
<script
|
||||
defer
|
||||
|
||||
@@ -25,7 +25,7 @@ function readContentDir(subdir) {
|
||||
return {
|
||||
...data,
|
||||
slug,
|
||||
body: marked(content),
|
||||
body: renderMarkdownContent(content),
|
||||
_source: `${subdir}/${filename}`
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,18 @@ function readContentDir(subdir) {
|
||||
.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) {
|
||||
const filepath = join(CONTENT_DIR, 'meta', filename)
|
||||
if (!existsSync(filepath)) return null
|
||||
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -96,4 +96,37 @@ describe('backend mail safety', () => {
|
||||
expect(await reserveToken(token)).toMatchObject({ status: 'processing' })
|
||||
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 { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { existsSync, readdirSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
||||
import { antiSpam } from '../middleware/antiSpam.js'
|
||||
import { sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
|
||||
const leadsMutex = new Mutex()
|
||||
const RESOURCES_DIR = join(__dirname, '..', '..', 'public', 'resources')
|
||||
|
||||
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
|
||||
// traversal (e.g. "../../etc/passwd") when building the download URL.
|
||||
const RESOURCE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
function loadLeads() {
|
||||
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')
|
||||
export function getAvailableResourceIds() {
|
||||
if (!existsSync(RESOURCES_DIR)) return []
|
||||
return readdirSync(RESOURCES_DIR, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith('.pdf') && entry.name !== 'speaker-cv-andre-knie.pdf')
|
||||
.map(entry => entry.name.slice(0, -4))
|
||||
}
|
||||
|
||||
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 (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||
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 (Object.keys(errors).length > 0) {
|
||||
return res.status(400).json({ errors })
|
||||
if (!resource_id || !getAvailableResourceIds().includes(resource_id)) {
|
||||
errors.resource_id = 'Diese Ressource ist derzeit nicht verfügbar.'
|
||||
}
|
||||
|
||||
await leadsMutex.runExclusive(async () => {
|
||||
const leads = loadLeads()
|
||||
leads.push({
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
resource_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
saveLeads(leads)
|
||||
})
|
||||
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||
|
||||
// Return download URL (resource files are in public/resources/)
|
||||
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` })
|
||||
try {
|
||||
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
|
||||
|
||||
@@ -59,20 +59,30 @@ export async function sendStakeholderNotification(type, data) {
|
||||
const subjects = {
|
||||
contact: `Neue Kontaktanfrage von ${data.name}`,
|
||||
'talk-request': `Neue Vortragsanfrage von ${data.name}`,
|
||||
'resource-download': `Ressourcenanfrage von ${data.name}`,
|
||||
}
|
||||
|
||||
const body = Object.entries(data)
|
||||
.map(([key, val]) => `${key}: ${val}`)
|
||||
.join('\n')
|
||||
const body = buildStakeholderMessage(type, data)
|
||||
|
||||
await t.sendMail({
|
||||
from: SMTP_USER,
|
||||
to: STAKEHOLDER_EMAIL,
|
||||
to: type === 'resource-download' ? 'kontakt@d-hive.de' : STAKEHOLDER_EMAIL,
|
||||
subject: subjects[type] || `Neue Anfrage (${type})`,
|
||||
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 }
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js'
|
||||
import {
|
||||
buildStakeholderMessage,
|
||||
isMailerReady,
|
||||
MailerNotReadyError,
|
||||
sendConfirmationEmail,
|
||||
} from './mailer.js'
|
||||
|
||||
describe('mailer readiness', () => {
|
||||
it('reports missing SMTP configuration without exposing credentials', async () => {
|
||||
@@ -10,3 +15,24 @@ describe('mailer readiness', () => {
|
||||
.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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dock { gap: 6px; padding: 6px 8px; }
|
||||
.dock-item { width: 38px; height: 38px; }
|
||||
.dock-container {
|
||||
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,27 @@ export default function BottomDock() {
|
||||
{ label: 'Home', path: '/', icon: <Home 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: '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: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> },
|
||||
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} />, secondary: true },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} />, secondary: true },
|
||||
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="dock-container">
|
||||
<nav className="dock">
|
||||
<nav className="dock" aria-label="Schnellnavigation">
|
||||
{items.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
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>
|
||||
<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}
|
||||
</Link>
|
||||
</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/)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,36 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
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' }) {
|
||||
const defaultTitle = "Dr. André Knie - Experte für Digitale Souveränität"
|
||||
const defaultDescription = "Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren."
|
||||
export default function SEOHead({ title, description, url, image, type = 'website', noindex = false }) {
|
||||
const pageTitle = normalizeTitle(title)
|
||||
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
|
||||
const siteDesc = description || defaultDescription
|
||||
const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de"
|
||||
const siteImage = image || "https://andreknie.de/images/og-image.webp"
|
||||
useEffect(() => {
|
||||
document.head.querySelectorAll('[data-seo-fallback]').forEach(node => node.remove())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<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:url" content={siteUrl} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:title" content={siteTitle} />
|
||||
<meta property="og:description" content={siteDesc} />
|
||||
<meta property="og:image" content={siteImage} />
|
||||
<meta property="og:description" content={siteDescription} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={siteUrl} />
|
||||
<meta property="twitter:title" content={siteTitle} />
|
||||
<meta property="twitter:description" content={siteDesc} />
|
||||
<meta property="twitter:image" content={siteImage} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={canonicalUrl} />
|
||||
<meta name="twitter:title" content={siteTitle} />
|
||||
<meta name="twitter:description" content={siteDescription} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -42,6 +42,9 @@
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--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);
|
||||
line-height: 1.6;
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { useMeta } from '../hooks/useContent'
|
||||
import { ExternalLink, Globe } from 'lucide-react'
|
||||
import SpeakerCvRequest from '../components/SpeakerCvRequest'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function About() {
|
||||
const { profile, about } = useMeta()
|
||||
@@ -12,6 +13,7 @@ export default function About() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<h1 className="section-title">Über <span className="text-gradient">mich</span></h1>
|
||||
|
||||
@@ -11,10 +11,16 @@ export default function ArticleSingle() {
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
|
||||
<h2>Artikel nicht gefunden</h2>
|
||||
<main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,9 +29,11 @@ export default function ArticleSingle() {
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="page-container article-single-page">
|
||||
<main className="page-container article-single-page">
|
||||
<SEOHead
|
||||
title={`${post.title} | Dr. André Knie`}
|
||||
url={`/artikel/${post.slug}`}
|
||||
type="article"
|
||||
title={post.title}
|
||||
description={post.excerpt || post.title}
|
||||
/>
|
||||
<div className="app-container" style={{ maxWidth: '800px', margin: '0 auto', paddingTop: '120px' }}>
|
||||
@@ -48,6 +56,6 @@ export default function ArticleSingle() {
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ export default function Articles() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="page-container articles-page">
|
||||
<main className="page-container articles-page">
|
||||
<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."
|
||||
/>
|
||||
<header className="page-header">
|
||||
@@ -102,6 +103,6 @@ export default function Articles() {
|
||||
<p className="no-articles">Keine Artikel für diese Suche gefunden.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function Confirmation() {
|
||||
<SEOHead
|
||||
title={isSuccess ? "Vielen Dank" : "Ein Fehler ist aufgetreten"}
|
||||
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' }}>
|
||||
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useContent, useMeta } from '../hooks/useContent'
|
||||
import ServiceAccordion from '../components/ServiceAccordion'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Consulting() {
|
||||
const { items } = useContent('consulting', { pageSize: 10 })
|
||||
@@ -8,6 +9,7 @@ export default function Consulting() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Leistungen</span></h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useFormSubmit } from '../hooks/useFormSubmit'
|
||||
import { validateContact } from '../utils/validation'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Contact() {
|
||||
const [form, setForm] = useState({ name: '', email: '', message: '', company_website: '' })
|
||||
@@ -18,6 +19,7 @@ export default function Contact() {
|
||||
if (success) {
|
||||
return (
|
||||
<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' }}>
|
||||
<h1 style={{ marginBottom: '16px' }}>Nachricht gesendet</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Du erhältst eine Bestätigungs-E-Mail. Bitte klicke den Link darin, damit deine Nachricht weitergeleitet wird.</p>
|
||||
@@ -28,6 +30,7 @@ export default function Contact() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Kontakt</span></h1>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Datenschutz() {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<SEOHead title="Datenschutz" description="Datenschutzerklärung von andreknie.de." url="/datenschutz" />
|
||||
<div className="app-container" style={{ maxWidth: '700px' }}>
|
||||
<h1 style={{ marginBottom: '32px' }}>Datenschutzerklärung</h1>
|
||||
<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>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>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><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcenanfragen</strong></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>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;
|
||||
}
|
||||
|
||||
@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 {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@@ -6,6 +6,7 @@ import UpcomingEvents from '../components/UpcomingEvents'
|
||||
import StatsCube from '../components/StatsCube'
|
||||
import DotCloudIcon from '../components/DotCloudIcon'
|
||||
import PhotoBand from '../components/PhotoBand'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
import './Home.css'
|
||||
|
||||
const PHOTO_BAND_IMAGES = [
|
||||
@@ -21,6 +22,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<SEOHead url="/" />
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="app-container hero-content">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Impressum() {
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<SEOHead title="Impressum" description="Impressum von andreknie.de." url="/impressum" />
|
||||
<div className="app-container" style={{ maxWidth: '700px' }}>
|
||||
<h1 style={{ marginBottom: '32px' }}>Impressum</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Search } from 'lucide-react'
|
||||
import { useContent } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
import { decodeHtml } from '../utils/decodeHtml'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Kniepunkt() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -21,6 +22,7 @@ export default function Kniepunkt() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Kniepunkt</span></h1>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useContentItem } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function KniepunktIssue() {
|
||||
const { slug } = useParams()
|
||||
@@ -10,6 +11,7 @@ export default function KniepunktIssue() {
|
||||
if (!issue) {
|
||||
return (
|
||||
<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' }}>
|
||||
<h1>Ausgabe nicht gefunden</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Diese Kniepunkt-Ausgabe existiert nicht.</p>
|
||||
@@ -21,6 +23,7 @@ export default function KniepunktIssue() {
|
||||
|
||||
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' }}>
|
||||
<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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<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' }}>
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { useContent } from '../hooks/useContent'
|
||||
import { formatDate } from '../utils/formatDate'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Podcast() {
|
||||
const { items: allEpisodes } = useContent('podcast', { visibility: 'all', pageSize: 100 })
|
||||
@@ -13,6 +14,7 @@ export default function Podcast() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<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 { 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 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 aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: '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)} />
|
||||
{errors.name && <p 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)} />
|
||||
{errors.email && <p 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)} />
|
||||
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
|
||||
|
||||
{serverError && <p role="alert" 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() {
|
||||
const { items } = useContent('resources', { pageSize: 20 })
|
||||
const [selectedResource, setSelectedResource] = useState(null)
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<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 style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
||||
{items.map(res => (
|
||||
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}>
|
||||
{items.map(resource => {
|
||||
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>
|
||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
||||
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
|
||||
</div>
|
||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
Download
|
||||
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{selected ? 'Schließen' : 'Material anfragen'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useContent, useMeta } from '../hooks/useContent'
|
||||
import SpeakingAccordion from '../components/SpeakingAccordion'
|
||||
import SEOHead from '../components/SEOHead'
|
||||
|
||||
export default function Speaking() {
|
||||
const { items: allTalks } = useContent('talks', { visibility: 'all', pageSize: 100 })
|
||||
@@ -9,6 +10,7 @@ export default function Speaking() {
|
||||
|
||||
return (
|
||||
<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="section-header">
|
||||
<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.company || data.company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||
if (data.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
|
||||
if (!data.resource_id) errors.resource_id = 'Resource-ID fehlt.'
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -125,4 +125,10 @@ describe('validateResourceDownload', () => {
|
||||
expect(result.valid).toBe(false)
|
||||
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