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:
+164
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user