Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0fbd077e | ||
|
|
4ca2c1ece8 | ||
|
|
215aacf4a4 | ||
|
|
b507199987 | ||
|
|
0334252fed | ||
|
|
8236cce85c | ||
|
|
71804bb2cc |
@@ -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