feat: Add professional portfolio images and Hero redesign

- Add 15 optimized WebP images for portfolio (21-53KB each)
- Redesign Hero section with full-width background image
- Add SSR support for Hero component
- Update About section with new portrait image
- Update Contact page with professional headshot
- Add images to Services/Leistungen page
- Add image optimization script (sharp)
- Update translations for gallery section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 23:33:28 +01:00
co-authored by Claude Opus 4.5
parent b1ec7b4d61
commit 87c7ebc5e3
75 changed files with 10271 additions and 1314 deletions
+164
View File
@@ -0,0 +1,164 @@
import fs from 'fs';
import path from 'path';
export interface BlogPost {
slug: string;
title: string;
date: string;
excerpt: string;
coverImage: string;
category?: string;
tags?: string[];
content: string;
}
const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts');
// Category mapping based on filename patterns
const categoryMap: Record<string, string> = {
'agentic': 'KI-Agenten',
'ai-agent': 'KI-Agenten',
'deepseek': 'KI-Entwicklung',
'openai': 'KI-Entwicklung',
'gpt': 'KI-Entwicklung',
'claude': 'KI-Entwicklung',
'langchain': 'KI-Entwicklung',
'langgraph': 'KI-Entwicklung',
'rag': 'KI-Entwicklung',
'llm': 'KI-Entwicklung',
'voice': 'Voice AI',
'vapi': 'Voice AI',
'speech': 'Voice AI',
'elevenlabs': 'Voice AI',
'whisper': 'Voice AI',
'n8n': 'Automatisierung',
'make': 'Automatisierung',
'zapier': 'Automatisierung',
'automation': 'Automatisierung',
'workflow': 'Automatisierung',
'scraping': 'Web Scraping',
'playwright': 'Web Scraping',
'puppeteer': 'Web Scraping',
'nextjs': 'Web Development',
'react': 'Web Development',
'typescript': 'Web Development',
'api': 'Backend',
'database': 'Backend',
'postgres': 'Backend',
'prisma': 'Backend',
'supabase': 'Backend',
'saas': 'SaaS',
'stripe': 'SaaS',
'subscription': 'SaaS',
'seo': 'SEO',
'performance': 'Performance',
'iot': 'IoT',
'raspberry': 'IoT',
'mqtt': 'IoT',
'home-assistant': 'Smart Home',
};
function detectCategory(filename: string, content: string): string {
const lowerFilename = filename.toLowerCase();
const lowerContent = content.toLowerCase().slice(0, 500);
for (const [keyword, category] of Object.entries(categoryMap)) {
if (lowerFilename.includes(keyword) || lowerContent.includes(keyword)) {
return category;
}
}
return 'Technologie';
}
function parseMarkdownPost(filename: string, content: string): BlogPost | null {
try {
// Extract title from first H1
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : filename.replace(/\.md$/, '');
// Extract meta description
const metaMatch = content.match(/\*\*Meta-Description:\*\*\s*(.+)/i);
const metaDescription = metaMatch ? metaMatch[1].trim() : '';
// Extract keywords
const keywordsMatch = content.match(/\*\*Keywords?:\*\*\s*(.+)/i);
const keywordsStr = keywordsMatch ? keywordsMatch[1].trim() : '';
const tags = keywordsStr ? keywordsStr.split(',').map(k => k.trim()).slice(0, 6) : [];
// Extract excerpt from introduction or meta description
let excerpt = metaDescription;
if (!excerpt) {
const introMatch = content.match(/## Einführung\s*\n\n(.+?)(?:\n\n|$)/s);
if (introMatch) {
excerpt = introMatch[1].trim().slice(0, 200) + '...';
}
}
// Generate slug from filename (remove number prefix)
const slug = filename
.replace(/\.md$/, '')
.replace(/^\d+-/, '');
// Extract date from filename number (approximate)
const numMatch = filename.match(/^(\d+)-/);
const postNum = numMatch ? parseInt(numMatch[1], 10) : 1;
// Generate dates starting from 2026-01-19 going backwards
const baseDate = new Date('2026-01-19');
baseDate.setDate(baseDate.getDate() - (postNum - 1));
const date = baseDate.toISOString().split('T')[0];
// Detect category
const category = detectCategory(filename, content);
// Cover image path
const coverImage = `/images/posts/${slug}/cover.jpg`;
return {
slug,
title,
date,
excerpt: excerpt || title,
coverImage,
category,
tags,
content,
};
} catch (error) {
console.error(`Error parsing ${filename}:`, error);
return null;
}
}
export function getAllBlogPosts(): BlogPost[] {
if (!fs.existsSync(BLOG_POSTS_DIR)) {
console.warn('Blog posts directory not found:', BLOG_POSTS_DIR);
return [];
}
const files = fs.readdirSync(BLOG_POSTS_DIR)
.filter(file => file.endsWith('.md'))
.sort();
const posts: BlogPost[] = [];
for (const file of files) {
const filePath = path.join(BLOG_POSTS_DIR, file);
const content = fs.readFileSync(filePath, 'utf-8');
const post = parseMarkdownPost(file, content);
if (post) {
posts.push(post);
}
}
// Sort by date descending (newest first)
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getBlogPostBySlug(slug: string): BlogPost | null {
const posts = getAllBlogPosts();
return posts.find(post => post.slug === slug) || null;
}
export function getBlogPostSlugs(): string[] {
return getAllBlogPosts().map(post => post.slug);
}
+311
View File
@@ -0,0 +1,311 @@
import React from 'react';
function parseInlineMarkdown(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
let remaining = text;
let keyIndex = 0;
while (remaining.length > 0) {
// Inline code
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
parts.push(
<code key={keyIndex++} className="bg-zinc-800 px-1.5 py-0.5 rounded text-[#697565] text-sm font-mono">
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// Bold
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
if (boldMatch) {
parts.push(<strong key={keyIndex++} className="font-semibold text-white">{boldMatch[1]}</strong>);
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// Italic
const italicMatch = remaining.match(/^\*([^*]+)\*/);
if (italicMatch) {
parts.push(<em key={keyIndex++} className="italic">{italicMatch[1]}</em>);
remaining = remaining.slice(italicMatch[0].length);
continue;
}
// Links
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
parts.push(
<a key={keyIndex++} href={linkMatch[2]} className="text-[#697565] hover:text-[#8a9a7e] underline" target="_blank" rel="noopener noreferrer">
{linkMatch[1]}
</a>
);
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// Regular character
const nextSpecial = remaining.search(/[`*\[]/);
if (nextSpecial === -1) {
parts.push(remaining);
break;
} else if (nextSpecial === 0) {
parts.push(remaining[0]);
remaining = remaining.slice(1);
} else {
parts.push(remaining.slice(0, nextSpecial));
remaining = remaining.slice(nextSpecial);
}
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function renderMarkdown(content: string): React.ReactNode {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
let i = 0;
let listItems: { type: 'ul' | 'ol'; items: React.ReactNode[] } | null = null;
let codeBlock: { language: string; lines: string[] } | null = null;
let tableRows: string[][] | null = null;
const flushList = () => {
if (listItems) {
if (listItems.type === 'ul') {
elements.push(
<ul key={`ul-${elements.length}`} className="list-disc list-inside space-y-2 my-4 text-zinc-300">
{listItems.items}
</ul>
);
} else {
elements.push(
<ol key={`ol-${elements.length}`} className="list-decimal list-inside space-y-2 my-4 text-zinc-300">
{listItems.items}
</ol>
);
}
listItems = null;
}
};
const flushTable = () => {
if (tableRows && tableRows.length > 0) {
const header = tableRows[0];
const body = tableRows.slice(2); // Skip header separator row
elements.push(
<div key={`table-${elements.length}`} className="overflow-x-auto my-6">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-zinc-700">
{header.map((cell, idx) => (
<th key={idx} className="px-4 py-2 text-left text-zinc-200 font-semibold">
{cell.trim()}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b border-zinc-800">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-4 py-2 text-zinc-300">
{cell.trim()}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
tableRows = null;
}
};
while (i < lines.length) {
const line = lines[i];
// Code block start/end
if (line.startsWith('```')) {
if (codeBlock) {
// End code block
elements.push(
<pre key={`code-${elements.length}`} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-x-auto my-6">
<code className="text-sm font-mono text-zinc-300">
{codeBlock.lines.join('\n')}
</code>
</pre>
);
codeBlock = null;
} else {
// Start code block
flushList();
flushTable();
const language = line.slice(3).trim();
codeBlock = { language, lines: [] };
}
i++;
continue;
}
// Inside code block
if (codeBlock) {
codeBlock.lines.push(line);
i++;
continue;
}
// Table row
if (line.startsWith('|') && line.endsWith('|')) {
flushList();
const cells = line.slice(1, -1).split('|');
if (!tableRows) {
tableRows = [];
}
tableRows.push(cells);
i++;
continue;
} else if (tableRows) {
flushTable();
}
// Headers
if (line.startsWith('#### ')) {
flushList();
elements.push(
<h4 key={`h4-${elements.length}`} className="text-lg font-semibold text-white mt-5 mb-2">
{parseInlineMarkdown(line.slice(5))}
</h4>
);
i++;
continue;
}
if (line.startsWith('### ')) {
flushList();
elements.push(
<h3 key={`h3-${elements.length}`} className="text-xl font-semibold text-white mt-6 mb-3">
{parseInlineMarkdown(line.slice(4))}
</h3>
);
i++;
continue;
}
if (line.startsWith('## ')) {
flushList();
elements.push(
<h2 key={`h2-${elements.length}`} className="text-2xl font-bold text-white mt-8 mb-4">
{parseInlineMarkdown(line.slice(3))}
</h2>
);
i++;
continue;
}
if (line.startsWith('# ')) {
flushList();
elements.push(
<h1 key={`h1-${elements.length}`} className="text-3xl font-bold text-white mt-8 mb-4">
{parseInlineMarkdown(line.slice(2))}
</h1>
);
i++;
continue;
}
// Unordered list items
if (line.match(/^[-*]\s/)) {
if (!listItems || listItems.type !== 'ul') {
flushList();
listItems = { type: 'ul', items: [] };
}
listItems.items.push(
<li key={`li-${listItems.items.length}`}>
{parseInlineMarkdown(line.replace(/^[-*]\s/, ''))}
</li>
);
i++;
continue;
}
// Ordered list items
if (line.match(/^\d+\.\s/)) {
if (!listItems || listItems.type !== 'ol') {
flushList();
listItems = { type: 'ol', items: [] };
}
listItems.items.push(
<li key={`li-${listItems.items.length}`}>
{parseInlineMarkdown(line.replace(/^\d+\.\s/, ''))}
</li>
);
i++;
continue;
}
// Checkbox list items
if (line.match(/^[-*]\s\[[ x]\]/i)) {
if (!listItems || listItems.type !== 'ul') {
flushList();
listItems = { type: 'ul', items: [] };
}
const checked = line.match(/^[-*]\s\[x\]/i);
const itemContent = line.replace(/^[-*]\s\[[ x]\]\s?/i, '');
listItems.items.push(
<li key={`li-${listItems.items.length}`} className="flex items-center gap-2">
<span className={checked ? 'text-green-500' : 'text-zinc-500'}>
{checked ? '✓' : '○'}
</span>
{parseInlineMarkdown(itemContent)}
</li>
);
i++;
continue;
}
// Horizontal rule
if (line.match(/^[-*_]{3,}$/)) {
flushList();
elements.push(<hr key={`hr-${elements.length}`} className="border-zinc-700 my-8" />);
i++;
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
elements.push(
<blockquote key={`quote-${elements.length}`} className="border-l-4 border-[#697565] pl-4 my-4 text-zinc-400 italic">
{parseInlineMarkdown(line.slice(2))}
</blockquote>
);
i++;
continue;
}
// Empty line
if (!line.trim()) {
flushList();
i++;
continue;
}
// Regular paragraph
flushList();
elements.push(
<p key={`p-${elements.length}`} className="text-zinc-300 mb-4 leading-relaxed">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
// Flush any remaining items
flushList();
flushTable();
return <div className="prose prose-invert prose-zinc max-w-none">{elements}</div>;
}