Migrate from Vite to Next.js 15 with SSR

- Replace Vite + React Router with Next.js 15 App Router
- Implement i18n with next-intl (URL-based: /de, /en, /sr)
- Add SSR/SSG for all pages (48 static pages generated)
- Setup Supabase SSR client for auth
- Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms
- Add Docker support with standalone output
- Replace i18next with next-intl JSON translations
- Use next/image for optimized images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 01:00:33 +01:00
co-authored by Claude Opus 4.5
parent a66f51b9a2
commit b1ec7b4d61
281 changed files with 8024 additions and 8901 deletions
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../utils/supabaseClient';
import { Users, Eye, Clock, TrendingUp, MousePointer, ArrowUpRight, ArrowDownRight } from 'lucide-react';
import SEO from '../components/SEO';
import PageTransition from '../components/PageTransition';
interface AnalyticsData {
pageViews: number;
uniqueVisitors: number;
avgTimeOnSite: string;
bounceRate: string;
conversions: number;
conversionRate: string;
activeUsers: number;
topPages: Array<{
path: string;
views: number;
change: number;
}>;
realtimeStats: {
currentVisitors: number;
pagesPerMinute: number;
};
events: Array<{
name: string;
count: number;
trend: number;
}>;
}
const DashboardPage = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [analyticsData, setAnalyticsData] = useState<AnalyticsData>({
pageViews: 0,
uniqueVisitors: 0,
avgTimeOnSite: '0:00',
bounceRate: '0%',
conversions: 0,
conversionRate: '0%',
activeUsers: 0,
topPages: [],
realtimeStats: {
currentVisitors: 0,
pagesPerMinute: 0
},
events: []
});
useEffect(() => {
checkAuth();
fetchAnalyticsData();
const interval = setInterval(fetchRealtimeData, 30000); // Update every 30 seconds
return () => clearInterval(interval);
}, []);
const checkAuth = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
navigate('/login');
}
setLoading(false);
};
const fetchAnalyticsData = async () => {
// Simulated data - replace with actual GA4 API calls
setAnalyticsData({
pageViews: 1234,
uniqueVisitors: 567,
avgTimeOnSite: '2:45',
bounceRate: '45%',
conversions: 89,
conversionRate: '15.7%',
activeUsers: 23,
topPages: [
{ path: '/', views: 450, change: 5.2 },
{ path: '/blog', views: 320, change: -2.1 },
{ path: '/portfolio', views: 280, change: 8.4 },
{ path: '/contact', views: 184, change: 3.7 }
],
realtimeStats: {
currentVisitors: 23,
pagesPerMinute: 4.2
},
events: [
{ name: 'Contact Form Submit', count: 45, trend: 12.3 },
{ name: 'Portfolio View', count: 156, trend: -4.2 },
{ name: 'CV Download', count: 78, trend: 8.7 },
{ name: 'Blog Read', count: 234, trend: 15.4 }
]
});
};
const fetchRealtimeData = async () => {
// Simulated real-time data update
setAnalyticsData(prev => ({
...prev,
realtimeStats: {
currentVisitors: Math.floor(Math.random() * 30) + 15,
pagesPerMinute: +(Math.random() * 5 + 2).toFixed(1)
}
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
);
}
return (
<PageTransition>
<SEO
title="Analytics Dashboard"
description="Website analytics dashboard"
/>
<div className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-12">
<h1 className="text-4xl font-bold">Analytics Dashboard</h1>
<div className="flex items-center space-x-2 bg-orange-500/10 text-orange-500 px-4 py-2 rounded-lg">
<div className="animate-pulse h-2 w-2 rounded-full bg-orange-500"></div>
<span className="text-sm font-medium">
{analyticsData.realtimeStats.currentVisitors} active users
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<div className="bg-gray-900/50 p-6 rounded-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">Page Views</h3>
<Eye className="h-6 w-6 text-orange-500" />
</div>
<p className="text-3xl font-bold">{analyticsData.pageViews}</p>
<p className="text-sm text-white/60 mt-2">
{analyticsData.realtimeStats.pagesPerMinute} per minute
</p>
</div>
<div className="bg-gray-900/50 p-6 rounded-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">Unique Visitors</h3>
<Users className="h-6 w-6 text-orange-500" />
</div>
<p className="text-3xl font-bold">{analyticsData.uniqueVisitors}</p>
<p className="text-sm text-white/60 mt-2">
{analyticsData.activeUsers} currently active
</p>
</div>
<div className="bg-gray-900/50 p-6 rounded-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">Conversions</h3>
<TrendingUp className="h-6 w-6 text-orange-500" />
</div>
<p className="text-3xl font-bold">{analyticsData.conversions}</p>
<p className="text-sm text-white/60 mt-2">
{analyticsData.conversionRate} conversion rate
</p>
</div>
<div className="bg-gray-900/50 p-6 rounded-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">Avg. Time on Site</h3>
<Clock className="h-6 w-6 text-orange-500" />
</div>
<p className="text-3xl font-bold">{analyticsData.avgTimeOnSite}</p>
<p className="text-sm text-white/60 mt-2">
{analyticsData.bounceRate} bounce rate
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
<div className="bg-gray-900/50 p-6 rounded-xl">
<h3 className="text-xl font-semibold mb-6">Top Pages</h3>
<div className="space-y-4">
{analyticsData.topPages.map((page, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<MousePointer className="h-4 w-4 text-white/40" />
<span className="text-white/80">{page.path}</span>
</div>
<div className="flex items-center space-x-4">
<span className="text-white/80">{page.views}</span>
<div className={`flex items-center ${page.change >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{page.change >= 0 ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
<span className="ml-1">{Math.abs(page.change)}%</span>
</div>
</div>
</div>
))}
</div>
</div>
<div className="bg-gray-900/50 p-6 rounded-xl">
<h3 className="text-xl font-semibold mb-6">Event Tracking</h3>
<div className="space-y-4">
{analyticsData.events.map((event, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-white/80">{event.name}</span>
<div className="flex items-center space-x-4">
<span className="text-white/80">{event.count}</span>
<div className={`flex items-center ${event.trend >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{event.trend >= 0 ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
<span className="ml-1">{Math.abs(event.trend)}%</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</PageTransition>
);
};
export default DashboardPage;
+93
View File
@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import SEO from '../components/SEO';
import PageTransition from '../components/PageTransition';
import Breadcrumbs from '../components/Breadcrumbs';
const ImprintPage = () => {
const { t } = useTranslation();
const schema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Impressum',
description: 'Impressum für die Portfolio-Website von Damjan Savić'
};
return (
<PageTransition>
<SEO
title="Impressum"
description="Impressum für die Portfolio-Website von Damjan Savić"
schema={schema}
/>
<div className="py-24">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs currentTitle="Impressum" />
<h1 className="text-4xl font-bold mb-8">Impressum</h1>
<div className="prose prose-invert max-w-none">
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Kontakt</h2>
<div className="bg-white/5 p-6 rounded-lg">
<p className="mb-2 text-lg font-semibold">Damjan Savić</p>
<p className="mb-2">Fullstack Developer & Digital Solutions Consultant</p>
<p className="mb-2"><strong>E-Mail:</strong></p>
<p className="mb-2">info@damjan-savic.com</p>
<p className="mb-4">Website: https://damjan-savic.com</p>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Haftungsausschluss</h2>
<h3 className="text-xl font-semibold mb-2 mt-4">Haftung für Inhalte</h3>
<p>Die Inhalte meiner Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">Haftung für Links</h3>
<p>Meine Website enthält Links zu externen Websites Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Urheberrecht</h2>
<p>Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.</p>
<p className="mt-2">Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitte ich um einen entsprechenden Hinweis.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Bildnachweise</h2>
<p>Die auf dieser Website verwendeten Bilder und Grafiken stammen aus folgenden Quellen:</p>
<ul className="list-disc list-inside mt-2">
<li>Eigene Aufnahmen und Erstellungen</li>
<li>Icons: Heroicons, Tabler Icons (MIT Lizenz)</li>
<li>Weitere Bildquellen sind direkt bei den jeweiligen Bildern angegeben</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Datenschutz</h2>
<p>Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.</p>
<p className="mt-2">Weitere Informationen zum Datenschutz finden Sie in unserer <a href="/privacy" className="text-blue-400 hover:text-blue-300 underline">Datenschutzerklärung</a>.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Technische Umsetzung</h2>
<div className="bg-white/5 p-4 rounded-lg">
<p className="mb-2"><strong>Design & Entwicklung:</strong> Damjan Savić</p>
<p><strong>Technologien:</strong> React, TypeScript, Tailwind CSS</p>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Copyright</h2>
<p>© {new Date().getFullYear()} Damjan Savić. Alle Rechte vorbehalten.</p>
</section>
</div>
</div>
</div>
</PageTransition>
);
};
export default ImprintPage;
+99
View File
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../utils/supabaseClient';
import { Lock } from 'lucide-react';
import SEO from '../components/SEO';
import PageTransition from '../components/PageTransition';
const LoginPage = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) throw error;
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<PageTransition>
<SEO
title="Login"
description="Login to access the dashboard"
/>
<div className="min-h-screen flex items-center justify-center py-24 px-4">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<Lock className="mx-auto h-12 w-12 text-orange-500" />
<h2 className="mt-6 text-3xl font-bold">Sign in to Dashboard</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-2">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-white/80 mb-2">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</PageTransition>
);
};
export default LoginPage;
+40
View File
@@ -0,0 +1,40 @@
import { useNavigate } from 'react-router-dom';
import { FileQuestion } from 'lucide-react';
import SEO from '../components/SEO';
const NotFoundPage = () => {
const navigate = useNavigate();
return (
<>
<SEO
title="404 - Page Not Found"
description="The page you're looking for doesn't exist."
/>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<FileQuestion className="h-16 w-16 text-orange-500 mx-auto mb-6" />
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl mb-2">Page Not Found</p>
<p className="text-white/70 mb-8">The page you're looking for doesn't exist or has been moved.</p>
<div className="space-x-4">
<button
onClick={() => navigate(-1)}
className="bg-gray-800 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Go Back
</button>
<button
onClick={() => navigate('/')}
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors"
>
Home Page
</button>
</div>
</div>
</div>
</>
);
};
export default NotFoundPage
+199
View File
@@ -0,0 +1,199 @@
import { useTranslation } from 'react-i18next';
import SEO from '../components/SEO';
import PageTransition from '../components/PageTransition';
import Breadcrumbs from '../components/Breadcrumbs';
const PrivacyPolicyPage = () => {
const { t } = useTranslation();
const schema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Datenschutzerklärung',
description: 'Datenschutzerklärung für die Portfolio-Website von Damjan Savić'
};
return (
<PageTransition>
<SEO
title="Datenschutzerklärung"
description="Datenschutzerklärung für die Portfolio-Website von Damjan Savić"
schema={schema}
/>
<div className="py-24">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs currentTitle="Datenschutzerklärung" />
<h1 className="text-4xl font-bold mb-8">Datenschutzerklärung</h1>
<div className="prose prose-invert max-w-none">
<p className="text-lg mb-8">Stand: {new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist:</p>
<div className="bg-white/5 p-4 rounded-lg mt-4">
<p className="mb-2"><strong>Damjan Savić</strong></p>
<p className="mb-2">Köln, Deutschland</p>
<p className="mb-2">E-Mail: contact@damjan-savic.com</p>
<p>Telefon: +49 151 74477788</p>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Allgemeine Hinweise</h2>
<p>Der Schutz Ihrer persönlichen Daten ist mir ein besonderes Anliegen. Ich verarbeite Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, TKG 2003). In dieser Datenschutzerklärung informiere ich Sie über die wichtigsten Aspekte der Datenverarbeitung im Rahmen meiner Website.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">2.1 Datenminimierung</h3>
<p>Ich erhebe nur solche personenbezogenen Daten, die für die Erfüllung der jeweiligen Zwecke erforderlich sind. Eine darüber hinausgehende Datenerhebung findet nicht statt.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">2.2 Rechtsgrundlagen</h3>
<p>Die Verarbeitung Ihrer Daten erfolgt auf Basis folgender Rechtsgrundlagen:</p>
<ul className="list-disc list-inside mb-4">
<li>Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)</li>
<li>Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)</li>
<li>Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen)</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. Datenerfassung auf dieser Website</h2>
<h3 className="text-xl font-semibold mb-2 mt-4">3.1 Cookies</h3>
<p>Diese Website verwendet Cookies. Dabei handelt es sich um kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert. Cookies helfen dabei, unser Angebot nutzerfreundlicher, effektiver und sicherer zu machen.</p>
<p className="mt-4"><strong>Folgende Arten von Cookies werden verwendet:</strong></p>
<ul className="list-disc list-inside mb-4">
<li><strong>Notwendige Cookies:</strong> Diese Cookies sind für den Betrieb der Website unerlässlich (z.B. Session-Cookies)</li>
<li><strong>Funktionale Cookies:</strong> Diese Cookies ermöglichen erweiterte Funktionen (z.B. Spracheinstellungen)</li>
<li><strong>Analyse-Cookies:</strong> Diese Cookies helfen uns, die Nutzung unserer Website zu verstehen (nur mit Ihrer Zustimmung)</li>
<li><strong>Marketing-Cookies:</strong> Diese Cookies werden für personalisierte Werbung verwendet (nur mit Ihrer Zustimmung)</li>
</ul>
<p>Sie können Ihre Cookie-Einstellungen jederzeit über den Cookie-Banner auf unserer Website anpassen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">3.2 Server-Log-Dateien</h3>
<p>Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch übermittelt. Dies sind:</p>
<ul className="list-disc list-inside mb-4">
<li>Browsertyp und Browserversion</li>
<li>Verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse (anonymisiert)</li>
</ul>
<p>Diese Daten sind nicht bestimmten Personen zuordenbar. Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen. Die Daten werden nach 7 Tagen automatisch gelöscht.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">3.3 Kontaktformular</h3>
<p>Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert.</p>
<p><strong>Verarbeitete Daten:</strong></p>
<ul className="list-disc list-inside mb-4">
<li>Name</li>
<li>E-Mail-Adresse</li>
<li>Betreff</li>
<li>Nachrichteninhalt</li>
<li>Datum und Uhrzeit der Anfrage</li>
</ul>
<p><strong>Rechtsgrundlage:</strong> Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Vertragsanbahnung) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Beantwortung Ihrer Anfrage).</p>
<p><strong>Speicherdauer:</strong> Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind, spätestens jedoch nach 6 Monaten.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Externe Dienste und Inhalte</h2>
<h3 className="text-xl font-semibold mb-2 mt-4">4.1 Google Analytics</h3>
<p>Diese Website benutzt Google Analytics, einen Webanalysedienst der Google LLC ("Google"). Google Analytics wird nur aktiviert, wenn Sie der Verwendung von Analyse-Cookies ausdrücklich zugestimmt haben.</p>
<p><strong>Verarbeitete Daten:</strong></p>
<ul className="list-disc list-inside mb-4">
<li>Anonymisierte IP-Adresse</li>
<li>Geräteinformationen</li>
<li>Browserinformationen</li>
<li>Nutzungsverhalten (besuchte Seiten, Verweildauer, etc.)</li>
</ul>
<p><strong>Zweck:</strong> Analyse der Websitenutzung zur Verbesserung unseres Angebots</p>
<p><strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)</p>
<p><strong>Speicherdauer:</strong> 14 Monate</p>
<p>Sie können die Speicherung der Cookies durch eine entsprechende Einstellung Ihrer Browser-Software verhindern oder Ihre Einwilligung jederzeit über unseren Cookie-Banner widerrufen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">4.2 Google Fonts</h3>
<p>Diese Website nutzt zur einheitlichen Darstellung von Schriftarten so genannte Google Fonts. Google Fonts werden nur geladen, wenn Sie der Verwendung funktionaler Cookies zugestimmt haben.</p>
<p><strong>Verarbeitete Daten:</strong> IP-Adresse</p>
<p><strong>Zweck:</strong> Einheitliche Darstellung von Schriftarten</p>
<p><strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)</p>
<h3 className="text-xl font-semibold mb-2 mt-4">4.3 Facebook Pixel</h3>
<p>Diese Website nutzt das Facebook-Pixel von Meta Platforms Inc. Das Facebook-Pixel wird nur aktiviert, wenn Sie der Verwendung von Marketing-Cookies ausdrücklich zugestimmt haben.</p>
<p><strong>Verarbeitete Daten:</strong></p>
<ul className="list-disc list-inside mb-4">
<li>Cookie-ID</li>
<li>Geräteinformationen</li>
<li>Nutzungsverhalten</li>
</ul>
<p><strong>Zweck:</strong> Remarketing und Conversion-Tracking</p>
<p><strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Ihre Rechte</h2>
<p>Nach der Datenschutz-Grundverordnung stehen Ihnen folgende Rechte zu:</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.1 Auskunftsrecht (Art. 15 DSGVO)</h3>
<p>Sie haben das Recht, Auskunft über Ihre bei uns gespeicherten personenbezogenen Daten zu erhalten.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.2 Recht auf Berichtigung (Art. 16 DSGVO)</h3>
<p>Sie haben das Recht, unrichtige oder unvollständige personenbezogene Daten berichtigen zu lassen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.3 Recht auf Löschung (Art. 17 DSGVO)</h3>
<p>Sie haben das Recht, die Löschung Ihrer personenbezogenen Daten zu verlangen, sofern die gesetzlichen Voraussetzungen vorliegen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.4 Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)</h3>
<p>Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu verlangen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.5 Recht auf Datenübertragbarkeit (Art. 20 DSGVO)</h3>
<p>Sie haben das Recht, Ihre personenbezogenen Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.6 Widerspruchsrecht (Art. 21 DSGVO)</h3>
<p>Sie haben das Recht, aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit gegen die Verarbeitung Sie betreffender personenbezogener Daten Widerspruch einzulegen.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.7 Recht auf Widerruf der Einwilligung</h3>
<p>Sie haben das Recht, Ihre einmal erteilte Einwilligung jederzeit zu widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung wird dadurch nicht berührt.</p>
<h3 className="text-xl font-semibold mb-2 mt-4">5.8 Beschwerderecht bei der Aufsichtsbehörde</h3>
<p>Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Die für uns zuständige Aufsichtsbehörde ist:</p>
<div className="bg-white/5 p-4 rounded-lg mt-4">
<p className="mb-2"><strong>Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen</strong></p>
<p className="mb-2">Postfach 20 04 44</p>
<p className="mb-2">40102 Düsseldorf</p>
<p className="mb-2">Telefon: 0211/38424-0</p>
<p>E-Mail: poststelle@ldi.nrw.de</p>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Datensicherheit</h2>
<p>Ich verwende innerhalb des Website-Besuchs das verbreitete SSL-Verfahren (Secure Socket Layer) in Verbindung mit der jeweils höchsten Verschlüsselungsstufe, die von Ihrem Browser unterstützt wird. Ob eine einzelne Seite unseres Internetauftrittes verschlüsselt übertragen wird, erkennen Sie an der geschlossenen Darstellung des Schüssel- beziehungsweise Schloss-Symbols in der unteren Statusleiste Ihres Browsers.</p>
<p>Darüber hinaus bediene ich mich geeigneter technischer und organisatorischer Sicherheitsmaßnahmen, um Ihre Daten gegen zufällige oder vorsätzliche Manipulationen, teilweisen oder vollständigen Verlust, Zerstörung oder gegen den unbefugten Zugriff Dritter zu schützen.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Aktualität und Änderung dieser Datenschutzerklärung</h2>
<p>Diese Datenschutzerklärung ist aktuell gültig und hat den Stand {new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' })}.</p>
<p>Durch die Weiterentwicklung unserer Website und Angebote darüber oder aufgrund geänderter gesetzlicher beziehungsweise behördlicher Vorgaben kann es notwendig werden, diese Datenschutzerklärung zu ändern. Die jeweils aktuelle Datenschutzerklärung kann jederzeit auf der Website unter /privacy von Ihnen abgerufen und ausgedruckt werden.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Kontakt</h2>
<p>Für Fragen zum Datenschutz können Sie sich jederzeit an mich wenden:</p>
<div className="bg-white/5 p-4 rounded-lg mt-4">
<p className="mb-2">E-Mail: privacy@damjan-savic.com</p>
<p>Telefon: +49 151 74477788</p>
</div>
</section>
</div>
</div>
</div>
</PageTransition>
);
};
export default PrivacyPolicyPage;
+81
View File
@@ -0,0 +1,81 @@
import { useTranslation } from 'react-i18next';
import SEO from '../components/SEO';
import PageTransition from '../components/PageTransition';
import Breadcrumbs from '../components/Breadcrumbs';
const TermsPage = () => {
const { t } = useTranslation();
const schema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Terms of Service',
description: 'Terms of Service for Damjan Savić\'s portfolio website'
};
return (
<PageTransition>
<SEO
title="Terms of Service"
description="Terms of Service for Damjan Savić's portfolio website"
schema={schema}
/>
<div className="py-24">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs currentTitle="Terms of Service" />
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
<div className="prose prose-invert max-w-none">
<p className="text-lg mb-8">Last updated: March 10, 2024</p>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Agreement to Terms</h2>
<p>By accessing our website, you agree to be bound by these Terms of Service and to comply with all applicable laws and regulations.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Intellectual Property Rights</h2>
<p>All content on this website, including but not limited to text, graphics, logos, images, and software, is the property of Damjan Savić and is protected by intellectual property laws.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. User Responsibilities</h2>
<p>When using our website, you agree to:</p>
<ul className="list-disc list-inside mb-4">
<li>Provide accurate information</li>
<li>Use the website legally and ethically</li>
<li>Not attempt to gain unauthorized access</li>
<li>Not interfere with the website's operation</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Limitation of Liability</h2>
<p>We shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the website.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Changes to Terms</h2>
<p>We reserve the right to modify these terms at any time. We will notify users of any material changes by posting the new Terms of Service on this page.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Governing Law</h2>
<p>These terms shall be governed by and construed in accordance with the laws of Germany, without regard to its conflict of law provisions.</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Contact Information</h2>
<p>For any questions about these Terms of Service, please contact us at:</p>
<p>Email: legal@damjan-savic.com</p>
<p>Address: {t('contact.address')}</p>
</section>
</div>
</div>
</div>
</PageTransition>
);
};
export default TermsPage;
+112
View File
@@ -0,0 +1,112 @@
import { motion } from 'framer-motion';
import { Terminal, Globe2, Code2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const Hero = () => {
const { t } = useTranslation();
// Sichere Abfrage der Sprachen
const getLanguages = () => {
try {
const languages = t('pages.about.hero.languages', { returnObjects: true });
return typeof languages === 'object' ? Object.entries(languages) : [];
} catch (error) {
console.error('Error loading languages:', error);
return [];
}
};
return (
<section className="relative min-h-screen text-white overflow-hidden">
{/* Main Content */}
<div className="flex flex-col items-center justify-center min-h-screen px-4 relative z-10">
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-white text-3xl font-bold tracking-wider mb-4"
>
{t('pages.about.hero.title')}
</motion.h1>
{/* Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="max-w-3xl text-center mb-12"
>
<p className="text-zinc-400 text-lg mb-8">
{t('pages.about.hero.description')}
</p>
</motion.div>
{/* Expertise Areas */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12"
>
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
<Terminal className="h-8 w-8 mb-4 text-white" />
<h3 className="text-lg font-semibold mb-2">
{t('pages.about.hero.expertise.processAutomation.title')}
</h3>
<p className="text-zinc-400 text-center text-sm">
{t('pages.about.hero.expertise.processAutomation.description')}
</p>
</div>
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
<Globe2 className="h-8 w-8 mb-4 text-white" />
<h3 className="text-lg font-semibold mb-2">
{t('pages.about.hero.expertise.systemIntegration.title')}
</h3>
<p className="text-zinc-400 text-center text-sm">
{t('pages.about.hero.expertise.systemIntegration.description')}
</p>
</div>
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
<Code2 className="h-8 w-8 mb-4 text-white" />
<h3 className="text-lg font-semibold mb-2">
{t('pages.about.hero.expertise.customDevelopment.title')}
</h3>
<p className="text-zinc-400 text-center text-sm">
{t('pages.about.hero.expertise.customDevelopment.description')}
</p>
</div>
</motion.div>
{/* Languages */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4"
>
{getLanguages().map(([key, value]) => (
<div
key={key}
className="flex flex-col items-center p-3 border border-zinc-800 rounded-lg bg-zinc-900/50 hover:bg-zinc-800/50 transition-colors"
>
<span className="text-white font-medium">{value}</span>
</div>
))}
</motion.div>
{/* About Me Link */}
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="mt-8 px-6 py-3 bg-white text-zinc-900 rounded-lg font-medium hover:bg-zinc-100 transition-colors"
>
{t('pages.about.hero.aboutMe')}
</motion.button>
</div>
</section>
);
};
export default Hero;
+156
View File
@@ -0,0 +1,156 @@
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { Briefcase, GraduationCap, Globe } from 'lucide-react';
interface WorkPosition {
title: string;
period: string;
company: string;
location: string;
highlights: string[];
}
const Journey = () => {
const { t } = useTranslation();
// Sichere Datenabruf-Funktionen
const getWorkPositions = (): WorkPosition[] => {
try {
const positions = t('pages.about.journey.work.positions', { returnObjects: true });
return Array.isArray(positions) ? (positions as WorkPosition[]) : [];
} catch (error) {
console.error('Error loading work positions:', error);
return [];
}
};
const getLanguageItems = () => {
try {
const items = t('pages.about.journey.languages.items', { returnObjects: true });
return typeof items === 'object' ? Object.entries(items) : [];
} catch (error) {
console.error('Error loading language items:', error);
return [];
}
};
return (
<section className="py-24 relative">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl font-bold text-white mb-4">
{t('pages.about.journey.title')}
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
{t('pages.about.journey.subtitle')}
</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Education Column */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="space-y-6"
>
<div className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg">
<div className="flex items-center gap-3 mb-6">
<GraduationCap className="h-5 w-5 text-zinc-400" />
<h3 className="text-lg font-medium text-white">
{t('pages.about.journey.education.title')}
</h3>
</div>
<div className="space-y-4">
<div>
<h4 className="text-white font-medium">
{t('pages.about.journey.education.degrees.masters.degree')}
</h4>
<p className="text-zinc-400 text-sm">
{t('pages.about.journey.education.degrees.masters.university')}
</p>
<p className="text-zinc-500 text-sm">
{t('pages.about.journey.education.degrees.masters.period')}
</p>
</div>
<div>
<h4 className="text-white font-medium">
{t('pages.about.journey.education.degrees.bachelors.degree')}
</h4>
<p className="text-zinc-400 text-sm">
{t('pages.about.journey.education.degrees.bachelors.university')}
</p>
<p className="text-zinc-500 text-sm">
{t('pages.about.journey.education.degrees.bachelors.period')}
</p>
</div>
</div>
</div>
{/* Languages */}
<div className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg">
<div className="flex items-center gap-3 mb-6">
<Globe className="h-5 w-5 text-zinc-400" />
<h3 className="text-lg font-medium text-white">
{t('pages.about.journey.languages.title')}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
{getLanguageItems().map(([key, item]) => (
<div key={key}>
<div className="text-zinc-300">{item.name}</div>
<div className="text-zinc-500 text-sm">{item.level}</div>
</div>
))}
</div>
</div>
</motion.div>
{/* Work History */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="lg:col-span-2 space-y-6"
>
{getWorkPositions().map((job, index) => (
<div
key={index}
className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-white">{job.title}</h3>
<span className="text-zinc-500 text-sm">{job.period}</span>
</div>
<div className="flex items-center gap-2 mb-4">
<Briefcase className="h-4 w-4 text-zinc-400" />
<p className="text-zinc-300">{job.company}</p>
<span className="text-zinc-500"> {job.location}</span>
</div>
<ul className="space-y-2">
{job.highlights.map((highlight: string, idx: number) => (
<li key={idx} className="text-zinc-400 text-sm flex items-start gap-2">
<span className="text-zinc-500"></span>
{highlight}
</li>
))}
</ul>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
};
export default Journey;
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
// Schema Generator Funktion
export const generateSchema = (t: (key: string, options?: any) => string) => ({
'@context': 'https://schema.org',
'@type': 'AboutPage',
name: t('schema.page.name'),
description: t('schema.page.description'),
mainEntity: {
'@type': 'Person',
name: 'Damjan Savić',
jobTitle: t('schema.person.jobTitle'),
description: t('schema.person.description'),
knowsLanguage: Object.values(t('schema.person.languages', { returnObjects: true })),
hasSkill: Object.values(t('schema.person.skills', { returnObjects: true }))
}
});
const Schema = () => {
const { t } = useTranslation();
useEffect(() => {
const schema = generateSchema(t);
// Entferne vorhandenes Schema
const existingSchema = document.querySelector('script[data-schema="about"]');
if (existingSchema) {
existingSchema.remove();
}
// Füge neues Schema hinzu
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute('data-schema', 'about');
script.textContent = JSON.stringify(schema);
document.head.appendChild(script);
// Cleanup beim Unmount
return () => {
const script = document.querySelector('script[data-schema="about"]');
if (script) {
script.remove();
}
};
}, [t]);
return null; // Schema-Komponente rendert nichts sichtbares
};
export default Schema;
@@ -0,0 +1,46 @@
// SkillBar.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
interface SkillBarProps {
name: string;
level: number;
className?: string;
'aria-label'?: string;
}
const SkillBar: React.FC<SkillBarProps> = ({ name, level, className = '', 'aria-label': ariaLabel }) => {
const { t } = useTranslation();
return (
<div className={`space-y-2 ${className}`}>
<div className="flex justify-between items-center">
<span className="text-white text-sm">{name}</span>
<span className="text-white text-sm">
{level}%
</span>
</div>
<div
className="h-2 bg-zinc-800 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={level}
aria-valuemin={0}
aria-valuemax={100}
aria-label={ariaLabel || t('pages.about.skills.screenReader.skillProgress', {
skill: name,
level
})}
>
<motion.div
className="h-full bg-zinc-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${level}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</div>
);
};
export default SkillBar;
@@ -0,0 +1,38 @@
// SkillGroup.tsx
import { useTranslation } from 'react-i18next';
import SkillBar from './SkillBar';
interface SkillGroupProps {
category: string;
items: {
name: string;
level: number;
}[];
className?: string;
}
const SkillGroup: React.FC<SkillGroupProps> = ({ category, items, className = '' }) => {
const { t } = useTranslation();
return (
<div
className={`bg-zinc-800/30 border border-zinc-800 p-6 rounded-lg ${className}`}
role="region"
aria-label={t('pages.about.skills.aria.skillGroup', { category })}
>
<h3 className="text-xl font-semibold mb-6 text-white">{category}</h3>
<div className="space-y-6">
{items.map((skill) => (
<SkillBar
key={skill.name}
name={skill.name}
level={skill.level}
aria-label={t('pages.about.skills.aria.skillBar', { skill: skill.name })}
/>
))}
</div>
</div>
);
};
export default SkillGroup;
+139
View File
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { Database, Server, Store, Code2, Box } from 'lucide-react';
interface SkillItem {
name: string;
level: number;
}
interface SkillGroup {
category: string;
items: SkillItem[];
}
const iconMap: { [key: string]: React.ReactNode } = {
'Python': <Code2 className="w-8 h-8" />,
'Server': <Server className="w-8 h-8" />,
'Google Ads': <Store className="w-8 h-8" />,
'Meta Ads': <Store className="w-8 h-8" />,
'Shopware': <Store className="w-8 h-8" />,
'Shopify': <Store className="w-8 h-8" />,
'WooCommerce': <Store className="w-8 h-8" />,
'JTL WAWI': <Box className="w-8 h-8" />,
'AirFlow': <Database className="w-8 h-8" />
};
const Skills = () => {
const { t } = useTranslation();
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const skillGroups = t('pages.about.skills.skillGroups', {
returnObjects: true
}) as SkillGroup[];
// Alle Skills aus allen Gruppen flach auflisten
const allSkills = skillGroups.flatMap(group => group.items);
return (
<section className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold text-white mb-12">
{t('pages.about.skills.title')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Skills Progress Bars */}
<div className="space-y-6">
{allSkills.map((skill) => (
<motion.div
key={skill.name}
className="space-y-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onHoverStart={() => setSelectedSkill(skill.name)}
onHoverEnd={() => setSelectedSkill(null)}
>
<div className="flex justify-between items-center">
<div className="flex flex-col">
<span className="text-white text-sm font-medium">
{skill.name}
</span>
{selectedSkill === skill.name && (
<span className="text-zinc-400 text-xs">
{t('pages.about.skills.ui.skillLevel', {
level: skill.level,
skill: skill.name
})}
</span>
)}
</div>
<span className="text-white text-sm">
{skill.level}%
</span>
</div>
<div
className="h-2 bg-zinc-800 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={skill.level}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t('pages.about.skills.aria.skillBar', { skill: skill.name })}
>
<motion.div
className={`h-full rounded-full ${
selectedSkill === skill.name ? 'bg-zinc-500' : 'bg-zinc-600'
}`}
initial={{ width: 0 }}
animate={{ width: `${skill.level}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</motion.div>
))}
</div>
{/* Skills Icons Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{allSkills.map((skill) => (
<motion.div
key={skill.name}
className={`p-6 rounded-xl bg-zinc-800 flex flex-col items-center justify-center gap-3 cursor-pointer transition-all duration-300 hover:bg-zinc-700 ${
selectedSkill === skill.name ? 'ring-2 ring-zinc-500' : ''
}`}
whileHover={{ scale: 1.05 }}
onClick={() => setSelectedSkill(skill.name === selectedSkill ? null : skill.name)}
role="button"
aria-pressed={selectedSkill === skill.name}
aria-label={t('pages.about.skills.aria.selectSkill')}
>
<div className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}>
{iconMap[skill.name] || <Box className="w-8 h-8" />}
</div>
<span className="text-white text-sm text-center">
{skill.name}
</span>
{selectedSkill === skill.name && (
<span className="text-zinc-400 text-xs text-center mt-1">
{t('pages.about.skills.screenReader.skillProgress', {
skill: skill.name,
level: skill.level
})}
</span>
)}
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default Skills;
@@ -0,0 +1,112 @@
import { motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface WorkflowStep {
number: string;
title: string;
}
const Workflow = () => {
const { t } = useTranslation();
const getWorkflowSteps = () => {
try {
const steps = t('pages.about.workflow.steps', { returnObjects: true });
return Array.isArray(steps) ? steps : [];
} catch (error) {
console.error('Error loading workflow steps:', error);
return [];
}
};
const workflowSteps = getWorkflowSteps();
const totalSteps = workflowSteps.length;
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 },
};
return (
<section
className="relative w-full py-24 px-4 sm:px-6 lg:px-8"
aria-label={t('pages.about.workflow.aria.workflowSection')}
>
{/* Section Title and Description */}
<div className="max-w-7xl mx-auto mb-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex flex-col gap-4"
>
<div className="flex items-center gap-4">
<ArrowRight className="h-6 w-6 text-zinc-500" />
<h2 className="text-white text-sm tracking-wider">
{t('pages.about.workflow.title')}
</h2>
</div>
<p className="text-zinc-400 text-sm pl-10">
{t('pages.about.workflow.description')}
</p>
</motion.div>
</div>
{/* Workflow Steps */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-7xl mx-auto"
role="list"
>
{workflowSteps.map((step: WorkflowStep) => (
<motion.div
key={step.number}
variants={itemVariants}
transition={{ duration: 0.5 }}
className="group relative"
role="listitem"
aria-label={t('pages.about.workflow.screenReader.stepProgress', {
number: step.number,
total: totalSteps,
title: step.title,
})}
>
<div className="flex items-center py-6 border-b border-zinc-800 group-hover:border-zinc-700 transition-colors duration-300">
<div
className="w-12 sm:w-20 text-sm text-zinc-600 font-light"
aria-label={t('pages.about.workflow.aria.stepNumber', {
number: step.number,
})}
>
{step.number}
</div>
<h3 className="text-sm sm:text-base text-zinc-400 group-hover:text-white transition-colors duration-300">
{step.title}
</h3>
</div>
{/* Line Indicator */}
<div
className="absolute left-0 bottom-0 w-0 h-px bg-white group-hover:w-full transition-all duration-500 ease-out"
aria-hidden="true"
/>
</motion.div>
))}
</motion.div>
</section>
);
};
export default Workflow;
+96
View File
@@ -0,0 +1,96 @@
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import SEO from '../../components/SEO';
import Hero from './components/Hero';
import Skills from './components/Skills';
import Workflow from './components/Workflow';
import Journey from './components/Journey';
import { generateSchema } from './components/Schema';
import { PersonSchema } from '../../components/schemas/PersonSchema';
const AboutPage = () => {
const { t } = useTranslation();
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
return (
<>
<SEO
title={t('pages.about.seo.title')}
description={t('pages.about.seo.description')}
schema={generateSchema(t)}
/>
<PersonSchema />
<div className="relative min-h-screen">
{/* Hero Section */}
<section className="min-h-screen relative">
<Hero />
</section>
{/* Skills Section */}
<motion.section
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={sectionVariants}
transition={{ duration: 0.5 }}
className="py-24 relative"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl font-bold text-white mb-4">
{t('pages.about.skills.title')}
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
{t('pages.about.skills.description')}
</p>
</motion.div>
<Skills />
</div>
</motion.section>
{/* Workflow Section */}
<motion.section
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={sectionVariants}
transition={{ duration: 0.5 }}
className="py-24 relative"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl font-bold text-white mb-4">
{t('pages.about.workflow.title')}
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
{t('pages.about.workflow.description')}
</p>
</motion.div>
<Workflow />
</div>
</motion.section>
{/* Journey Section */}
<Journey />
</div>
</>
);
};
export default AboutPage;
+519
View File
@@ -0,0 +1,519 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Calendar, ArrowLeft, Tag, Loader2, Folder } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import PageTransition from '../../../components/PageTransition';
import SEO from '../../../components/SEO';
import { trackBlogPostView } from '../../../services/gtm';
import { blog as blogDe } from '../../../i18n/locales/de/blog/';
import { blog as blogEn } from '../../../i18n/locales/en/blog';
import { blog as blogSr } from '../../../i18n/locales/sr/blog';
/* =======================
Typdefinitionen
========================== */
// Typ für einen Blog-Post, wie er in der Komponente genutzt wird (enthält slug)
interface BlogPostType {
slug: string;
title: string;
excerpt: string;
date: string;
coverImage?: string;
category?: string;
tags?: string[];
content: {
intro: {
title: string;
description: string;
};
background: {
title: string;
systems: Record<string, string>;
challenges: Record<string, string>;
};
tech: {
title: string;
components: {
title: string;
code: string;
};
};
api: {
title: string;
apparel_magic: {
title: string;
description: string;
};
};
conclusion: {
title: string;
requirements: Record<string, string>;
results: string;
};
};
}
// Typ für einen Blog-Post in der Konfiguration (ohne slug)
type BlogPostConfig = Omit<BlogPostType, 'slug'>;
// Typ für die Blog-Konfiguration (UI-Text, Kategorien, Posts)
interface BlogConfig {
meta?: {
title: string;
description: string;
header: unknown;
};
ui: {
loading: {
post: string;
};
notFound: {
title: string;
message: string;
};
backToBlog: string;
};
categories: Record<string, string>;
posts: Record<string, BlogPostConfig>;
}
/* =======================
BlogPost Component
========================== */
const BlogPost: React.FC = () => {
const { slug } = useParams();
const navigate = useNavigate();
const { i18n } = useTranslation();
const [post, setPost] = useState<BlogPostType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Memoized Funktion zur Ermittlung der aktuellen Blog-Konfiguration anhand der Sprache.
const getBlogConfig = useCallback((): BlogConfig => {
switch (i18n.language) {
case 'en':
return blogEn as unknown as BlogConfig;
case 'sr':
return blogSr as unknown as BlogConfig;
case 'de':
default:
return blogDe as unknown as BlogConfig;
}
}, [i18n.language]);
const blog = getBlogConfig();
useEffect(() => {
const loadPost = async () => {
setLoading(true);
try {
if (slug) {
const currentBlog = getBlogConfig();
const postData = currentBlog.posts[slug];
if (postData) {
// Wir fügen hier den slug hinzu, da er in der Konfiguration nicht enthalten ist.
setPost({
...postData,
slug,
});
// Track blog post view
trackBlogPostView(postData.title, postData.category);
} else {
// Statt eine Exception zu werfen, setzen wir den Fehler direkt.
setError(new Error('Post not found'));
}
}
} catch (err) {
console.error('Error loading blog post:', err);
setError(err instanceof Error ? err : new Error('Failed to load post'));
} finally {
setLoading(false);
}
};
loadPost();
}, [slug, getBlogConfig]);
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const getFormattedDate = (date: string): string => {
const languageMap: Record<string, string> = {
de: 'de-DE',
en: 'en-US',
sr: 'sr-RS',
};
return new Date(date).toLocaleDateString(
languageMap[i18n.language] || 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center space-y-4"
>
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
<p className="text-zinc-400 text-sm">{blog.ui.loading.post}</p>
</motion.div>
</div>
);
}
if (error || !post) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center space-y-6 px-4 text-center"
>
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">{blog.ui.notFound.title}</h3>
<p className="text-zinc-400 text-sm max-w-md">
{error?.message || blog.ui.notFound.message}
</p>
</div>
<Link
to="/blog"
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{blog.ui.backToBlog}</span>
</Link>
</motion.div>
</div>
);
}
// Article Schema for SEO
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.excerpt,
"datePublished": post.date,
"dateModified": post.date,
"author": {
"@type": "Person",
"name": "Damjan Savić",
"url": "https://damjan-savic.com"
},
"publisher": {
"@type": "Person",
"name": "Damjan Savić",
"logo": {
"@type": "ImageObject",
"url": "https://damjan-savic.com/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://damjan-savic.com/blog/${post.slug}`
},
"articleSection": post.category ? blog.categories[post.category] : undefined,
"keywords": post.tags?.join(', ')
};
return (
<PageTransition>
<SEO title={`${post.title} - Blog`} description={post.excerpt} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<main className="min-h-screen">
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
<motion.div variants={containerVariants} initial="hidden" animate="visible">
{/* Back Navigation */}
<motion.div variants={itemVariants} className="mb-8">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{blog.ui.backToBlog}</span>
</button>
</motion.div>
{/* Post Header */}
<div className="mb-12">
<motion.div
variants={itemVariants}
className="flex flex-wrap items-center gap-4 mb-6"
>
<div className="flex items-center gap-2 text-zinc-400">
<Calendar className="h-4 w-4" />
<time>{getFormattedDate(post.date)}</time>
</div>
{post.category && (
<div className="flex items-center gap-2 text-zinc-400">
<Folder className="h-4 w-4" />
<span>{blog.categories[post.category] || post.category}</span>
</div>
)}
</motion.div>
<motion.h1
variants={itemVariants}
className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-8"
>
{post.title}
</motion.h1>
</div>
{/* Cover Image */}
{post.coverImage && (
<motion.div variants={itemVariants} className="mb-12">
<div className="relative rounded-lg overflow-hidden bg-zinc-800">
<img
src={post.coverImage}
alt={post.title}
className="w-full aspect-video object-cover"
loading="lazy"
/>
</div>
</motion.div>
)}
{/* Content */}
<motion.div
variants={itemVariants}
className="prose prose-invert max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-a:text-white prose-a:no-underline hover:prose-a:text-zinc-300
prose-strong:text-white
prose-code:text-zinc-300 prose-code:bg-zinc-800/50
prose-pre:bg-zinc-800/50 prose-pre:border prose-pre:border-zinc-800
prose-img:rounded-lg
prose-blockquote:border-l-zinc-700 prose-blockquote:text-zinc-400"
>
{/* Intro Section */}
{post.content.intro && (
<section>
<h2>{post.content.intro.title}</h2>
<p>{post.content.intro.description}</p>
</section>
)}
{/* Video Embed (for posts with videoUrl) */}
{(post as unknown as { videoUrl?: string }).videoUrl && (
<section className="my-8">
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: '12px' }}>
<iframe
src={`${(post as unknown as { videoUrl: string }).videoUrl}/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0`}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allowFullScreen
loading="lazy"
/>
</div>
</section>
)}
{/* Background Section (optional) */}
{post.content.background && (
<section>
<h2>{post.content.background.title}</h2>
{post.content.background.systems && (
<div className="mb-6">
<h3>Systems</h3>
<ul>
{Object.values(post.content.background.systems).map((system, index) => (
<li key={index}>{system}</li>
))}
</ul>
</div>
)}
{post.content.background.challenges && (
<div>
<h3>Challenges</h3>
<ul>
{Object.values(post.content.background.challenges).map((challenge, index) => (
<li key={index}>{challenge}</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Technical Architecture (optional) */}
{post.content.tech && (
<section>
<h2>{post.content.tech.title}</h2>
{post.content.tech.components && (
<>
<h3>{post.content.tech.components.title}</h3>
{post.content.tech.components.code && (
<pre>
<code>{post.content.tech.components.code}</code>
</pre>
)}
</>
)}
</section>
)}
{/* API Section (optional) */}
{post.content.api && (
<section>
<h2>{post.content.api.title}</h2>
{post.content.api.apparel_magic && (
<div className="mb-6">
<h3>{post.content.api.apparel_magic.title}</h3>
<p>{post.content.api.apparel_magic.description}</p>
</div>
)}
</section>
)}
{/* Workflow Section (for ad-creatives style posts) */}
{(post.content as unknown as { workflow?: { title: string; steps: Record<string, { title: string; description: string; points?: string[]; advantages?: string[]; steps?: string[] }> } }).workflow && (
<section>
<h2>{(post.content as unknown as { workflow: { title: string } }).workflow.title}</h2>
{Object.values((post.content as unknown as { workflow: { steps: Record<string, { title: string; description: string; points?: string[]; advantages?: string[]; steps?: string[] }> } }).workflow.steps).map((step, index) => (
<div key={index} className="mb-6">
<h3>{step.title}</h3>
<p>{step.description}</p>
{step.points && (
<ul>
{step.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
)}
{step.advantages && (
<ul>
{step.advantages.map((adv, i) => (
<li key={i}>{adv}</li>
))}
</ul>
)}
{step.steps && (
<ul>
{step.steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
)}
</div>
))}
</section>
)}
{/* Advantages Section (for ad-creatives style posts) */}
{(post.content as unknown as { advantages?: { title: string; efficiency?: { title: string; points: string[] }; scalability?: { title: string; points: string[] }; consistency?: { title: string; points: string[] } } }).advantages && (
<section>
<h2>{(post.content as unknown as { advantages: { title: string } }).advantages.title}</h2>
{(post.content as unknown as { advantages: { efficiency?: { title: string; points: string[] } } }).advantages.efficiency && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { efficiency: { title: string } } }).advantages.efficiency.title}</h3>
<ul>
{(post.content as unknown as { advantages: { efficiency: { points: string[] } } }).advantages.efficiency.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{(post.content as unknown as { advantages: { scalability?: { title: string; points: string[] } } }).advantages.scalability && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { scalability: { title: string } } }).advantages.scalability.title}</h3>
<ul>
{(post.content as unknown as { advantages: { scalability: { points: string[] } } }).advantages.scalability.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{(post.content as unknown as { advantages: { consistency?: { title: string; points: string[] } } }).advantages.consistency && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { consistency: { title: string } } }).advantages.consistency.title}</h3>
<ul>
{(post.content as unknown as { advantages: { consistency: { points: string[] } } }).advantages.consistency.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Conclusion */}
{post.content.conclusion && (
<section>
<h2>{post.content.conclusion.title}</h2>
{post.content.conclusion.requirements && (
<ul>
{Object.values(post.content.conclusion.requirements).map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
)}
{post.content.conclusion.results && (
<p>{post.content.conclusion.results}</p>
)}
{(post.content.conclusion as unknown as { description?: string }).description && (
<p>{(post.content.conclusion as unknown as { description: string }).description}</p>
)}
{(post.content.conclusion as unknown as { keyPoints?: string[] }).keyPoints && (
<ul>
{(post.content.conclusion as unknown as { keyPoints: string[] }).keyPoints.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
)}
</section>
)}
</motion.div>
{/* Tags */}
{post.tags && post.tags.length > 0 && (
<motion.div variants={itemVariants} className="mt-12 pt-6 border-t border-zinc-800">
<div className="flex items-center gap-4">
<Tag className="h-4 w-4 text-zinc-400" />
<div className="flex flex-wrap gap-2">
{post.tags.map((tag: string) => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</motion.div>
)}
</motion.div>
</article>
</main>
</PageTransition>
);
};
export default BlogPost;
@@ -0,0 +1,196 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, ExternalLink, Tag } from 'lucide-react';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { blog as blogDe } from '../../../i18n/locales/de/blog/index';
import { blog as blogEn } from '../../../i18n/locales/en/blog/index';
import { blog as blogSr } from '../../../i18n/locales/sr/blog/index';
// Eigene Typdefinition, da 'BlogPostFrontmatter' nicht aus mdxLoader exportiert wird.
export interface BlogPostFrontmatter {
title: string;
date: string;
excerpt: string;
coverImage: string;
category?: string;
tags?: string[];
}
interface BlogPostCardProps {
post: BlogPostFrontmatter & { slug: string };
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post }) => {
const { i18n } = useTranslation();
// Ermittelt die aktuelle Blog-Konfiguration anhand der Sprache
const getBlogConfig = () => {
switch (i18n.language) {
case 'en':
return blogEn;
case 'sr':
return blogSr;
case 'de':
default:
return blogDe;
}
};
const blog = getBlogConfig();
// Übersetzte Post-Daten ermitteln
const getTranslatedPost = () => {
const blogPost = blog.posts[post.slug as keyof typeof blog.posts];
return {
...post,
title: blogPost?.title || post.title,
excerpt: blogPost?.excerpt || post.excerpt,
category: blogPost?.category || post.category,
tags: blogPost?.tags || post.tags,
};
};
const translatedPost = getTranslatedPost();
// Bildpfad ermitteln
const getImagePath = (coverImage: string) => {
// Wenn der Pfad bereits absolut ist und mit "http" beginnt
if (coverImage.startsWith('http')) {
return coverImage;
}
// Ersetze "/blog/" mit "/images/posts/"
return coverImage.replace('/blog/', '/images/posts/');
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
// Datum formatiert in der aktuellen Sprache
const getFormattedDate = (date: string) => {
const languageMap = {
de: 'de-DE',
en: 'en-US',
sr: 'sr-RS',
};
return new Date(date).toLocaleDateString(
languageMap[i18n.language as keyof typeof languageMap] || 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
);
};
return (
<Link
to={`/blog/${post.slug}`}
className="group relative block bg-zinc-900/50 backdrop-blur-sm
rounded-xl shadow-lg hover:shadow-2xl
ring-1 ring-zinc-800 hover:ring-zinc-700
transition-all duration-500 focus:outline-none focus:ring-2
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
transform hover:-translate-y-1"
>
<motion.div
variants={itemVariants}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-xl"
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
{/* Image Container - same as PortfolioCard */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<img
src={getImagePath(post.coverImage)}
alt={translatedPost.title}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
group-hover:scale-110"
loading="lazy"
style={{ transformOrigin: 'center center' }}
onError={(e) => {
console.error('Image load error:', e);
}}
/>
{/* Gradient overlay that extends beyond the image */}
<div className="absolute inset-x-0 -bottom-20 top-0 bg-gradient-to-t from-zinc-900 via-zinc-900/70 to-transparent
opacity-95" />
</div>
{/* Content - with relative positioning to overlap the gradient */}
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
{/* Post Info */}
<div className="flex items-center gap-4 mb-4 text-zinc-500">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>{getFormattedDate(post.date)}</span>
</div>
{translatedPost.tags && translatedPost.tags.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" />
<span>
{blog.ui.tagsCount
? blog.ui.tagsCount.replace('{{count}}', translatedPost.tags.length.toString())
: `${translatedPost.tags.length} Tags`}
</span>
</div>
)}
</div>
{/* Title & Description */}
<h3 className="text-xl font-semibold text-white mb-3
group-hover:text-zinc-100 transition-colors duration-300">
{translatedPost.title}
</h3>
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
group-hover:text-zinc-300 transition-colors duration-300">
{translatedPost.excerpt}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{translatedPost.tags && translatedPost.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-zinc-800/50
text-sm text-zinc-400 rounded-full
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
transition-all duration-300"
>
{tag}
</span>
))}
{translatedPost.tags && translatedPost.tags.length > 3 && (
<span className="text-sm text-zinc-600">
+{translatedPost.tags.length - 3} {blog.ui.more || 'more'}
</span>
)}
</div>
{/* Link Icon - same as PortfolioCard */}
<div className="absolute top-6 right-6 p-2.5 rounded-full
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
opacity-0 group-hover:opacity-100
transform translate-y-2 group-hover:translate-y-0
transition-all duration-300">
<ExternalLink className="h-4 w-4 text-zinc-400" />
</div>
</div>
{/* Subtle Hover Border */}
<div className="absolute inset-0 rounded-xl pointer-events-none
ring-1 ring-zinc-600/0 group-hover:ring-zinc-600/30
transition-all duration-500" />
</motion.div>
</Link>
);
};
export default BlogPostCard;
+185
View File
@@ -0,0 +1,185 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Loader2, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import PageTransition from '../../components/PageTransition';
import SEO from '../../components/SEO';
import BlogPostCard from './components/BlogpostCard';
import type { BlogPost } from './utils/mdxLoader';
// Import aller Sprachversionen
import { blog as blogDe } from '../../i18n/locales/de/blog/index';
import { blog as blogEn } from '../../i18n/locales/en/blog/index';
import { blog as blogSr } from '../../i18n/locales/sr/blog/index';
const BlogPage: React.FC = () => {
const { i18n } = useTranslation();
// Wir erwarten hier Posts, bei denen coverImage garantiert ein string ist und slug vorhanden ist.
const [posts, setPosts] = useState<(BlogPost & { coverImage: string; slug: string })[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Ermittelt die aktuelle Blog-Konfiguration anhand der Sprache.
const getBlogConfig = () => {
switch (i18n.language) {
case 'en':
return blogEn;
case 'sr':
return blogSr;
case 'de':
default:
return blogDe;
}
};
const blog = getBlogConfig();
useEffect(() => {
const loadPosts = async () => {
setLoading(true);
try {
// Aus der aktuellen Sprachkonfiguration werden alle Posts extrahiert.
const currentBlog = getBlogConfig();
const blogPosts = Object.entries(currentBlog.posts).map(([slug, post]) => ({
...post,
slug,
// Falls coverImage nicht definiert ist, setzen wir einen leeren String als Fallback.
coverImage: post.coverImage ?? ''
})) as (BlogPost & { coverImage: string; slug: string })[];
setPosts(blogPosts);
} catch (error) {
console.error('Error loading blog posts:', error);
setError(
error instanceof Error
? error
: new Error(blog.ui.errors.posts)
);
} finally {
setLoading(false);
}
};
loadPosts();
}, [i18n.language]);
const schema = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: blog.meta.title,
description: blog.meta.description
};
const metaDescription = blog.meta.description;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center space-y-4"
>
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
<p className="text-zinc-400 text-sm">{blog.ui.loading.posts}</p>
</motion.div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center space-y-6 px-4 text-center"
>
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">{blog.ui.errors.posts}</h3>
<p className="text-zinc-400 text-sm max-w-md">{error.message}</p>
</div>
</motion.div>
</div>
);
}
return (
<PageTransition>
{/* SEO-Aufruf: Wir übergeben hier nur die Properties, die im SEOProps-Typ definiert sind */}
<SEO
title={blog.meta.title}
description={metaDescription}
schema={schema}
/>
<main className="min-h-screen">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
{/* Header Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-12 sm:mb-16 lg:mb-20"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex items-center gap-4 mb-8"
>
<ArrowRight className="h-5 w-5 text-zinc-500" />
<h2 className="text-white text-sm tracking-wider">BLOG</h2>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6"
>
{blog.meta.header.title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="text-lg sm:text-xl text-zinc-400 max-w-2xl"
>
{blog.meta.header.subtitle}
</motion.p>
</motion.div>
{/* Blog Posts Grid */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8"
>
{posts.map((post, index) => (
<motion.div
key={post.slug}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 * (index + 4) }}
>
<BlogPostCard post={post} />
</motion.div>
))}
</motion.div>
{/* Empty State */}
{posts.length === 0 && !loading && !error && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="text-center py-12"
>
<p className="text-zinc-400">{blog.ui.notFound.message}</p>
</motion.div>
)}
</div>
</main>
</PageTransition>
);
};
export default BlogPage;
@@ -0,0 +1,229 @@
---
title: "ApparelMagic und TradeByte: Analyse komplexer Integrationen"
date: "2024-02-09"
excerpt: "Detaillierte Analyse einer E-Commerce-Integration zwischen Apparel Magic und Breuninger via TradeByte"
category: "System Integration"
coverImage: "/images/posts/erp-integration-breuninger/cover.jpg"
tags: ["ERP", "Apparel Magic", "TradeByte", "API Integration", "Python", "MariaDB"]
---
# Apparel Magic meets Breuninger: Eine technische Analyse der TradeByte-Integration
Die Integration von E-Commerce-Systemen mit etablierten Marktplätzen stellt Unternehmen vor besondere Herausforderungen. In diesem Artikel teile ich meine Erfahrungen aus einem komplexen Integrationsprojekt zwischen Apparel Magic als ERP-System und der Breuninger-Plattform über TradeByte.
## Projekthintergrund
Das Projekt umfasste die Integration von zwei Hauptsystemen:
- Apparel Magic als ERP-System für Produktdaten und Bestandsmanagement
- TradeByte als Middleware für die Breuninger-Plattform
Die Hauptherausforderungen lagen in:
- Bidirektionale Synchronisation von Bestellungen
- Automatisierte Kundenanlage
- Verwaltung von Lieferscheinen
- Bestandsmanagement in Echtzeit
## Technische Architektur
### Zentrale Komponenten
```python
# Core system architecture
SYSTEMS = {
'apparelmagic': {
'type': 'ERP',
'api': 'REST',
'auth': 'Token'
},
'tradebyte': {
'type': 'Middleware',
'api': 'REST + XML',
'auth': 'Basic'
}
}
```
### Middleware-Datenbank
Die MariaDB-Datenbank fungiert als zentraler Datenspeicher für die Integration:
```sql
CREATE TABLE a_order (
order_id INT PRIMARY KEY AUTO_INCREMENT,
tb_order_id VARCHAR(50),
apparelmagic_account_number VARCHAR(50),
apparelmagic_warehouse_id INT,
apparelmagic_division_id INT,
breuninger_channel_id VARCHAR(50),
breuninger_channel_number VARCHAR(50),
breuninger_order_date DATETIME,
breuninger_payment_type VARCHAR(50),
breuninger_shipment_price DECIMAL(10,2),
delivery_note VARCHAR(255),
done DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## API-Integrationen
### Apparel Magic API
Die REST-API von Apparel Magic wird für Kundenanlage und Bestandsmanagement verwendet:
```python
def make_request(endpoint, method='GET', data=None):
url = f"{APPARELMAGIC_API_URL}{endpoint}"
params = {
"time": str(int(time.time())),
"token": APPARELMAGIC_TOKEN
}
try:
if method == 'GET':
response = requests.get(url, params=params)
elif method == 'POST':
response = requests.post(url, params=params, json=data)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
return response.json()
except requests.RequestException as e:
logger.error(f"Error making request to ApparelMagic API: {e}")
raise
```
### TradeByte Integration
Die TradeByte-API verwendet eine Kombination aus REST und XML:
```python
def get_orders():
response = make_request("orders/?channel=289")
if not response.content:
logger.info("No new orders to process. All existing orders have been exported.")
return []
try:
root = ET.fromstring(response.content)
orders = []
for order in root.findall('.//ORDER'):
order_data = {child.tag: child.text for child in order.iter() if child.tag != 'ORDER'}
orders.append(order_data)
return orders
except ET.ParseError as e:
logger.error(f"Failed to parse XML response: {e}")
return []
```
## Bestellprozess
Der Bestellprozess folgt einem klaren Ablauf:
1. Regelmäßiger Abruf neuer Bestellungen von TradeByte
2. Automatische Kundenanlage in Apparel Magic
3. Bestellverarbeitung und Statusaktualisierung
4. Generierung und Speicherung von Lieferscheinen
### Lieferschein-Management
```python
def fetch_and_save_delivery_note(tb_order_id):
logger.info(f"Fetching delivery note for order {tb_order_id}")
delivery_note_content = get_delivery_note(tb_order_id)
if delivery_note_content:
try:
file_path = save_delivery_note(tb_order_id, delivery_note_content)
update_order_with_delivery_note(tb_order_id, file_path)
except Exception as e:
logger.error(f"Error saving delivery note for order {tb_order_id}: {e}")
```
## Herausforderungen und Lösungen
### 1. Status-Management
Eine besondere Herausforderung war das Management von Bestellstatus:
```python
def update_order_status(order_response: OrderResponse):
logger.info(f"Updating status for order {order_response.tb_order_id}")
xml_data = generate_xml(order_response)
status_code, response_text = send_order_status(xml_data)
if status_code == 200:
logger.info(f"Successfully updated order status")
mark_order_as_done(order_response.tb_order_id)
else:
logger.error(f"Failed to update status. Code: {status_code}")
```
### 2. Fehlerbehandlung und Monitoring
Robuste Fehlerbehandlung war essentiell für den Produktivbetrieb:
```python
@contextmanager
def get_db_connection():
connection = None
try:
config = DB_CONFIG.copy()
config['charset'] = 'utf8mb4'
config['collation'] = 'utf8mb4_general_ci'
connection = mysql.connector.connect(**config)
logger.info("Database connection established")
yield connection
except Error as e:
logger.error(f"Database error: {e}")
raise
finally:
if connection and connection.is_connected():
connection.close()
```
## Best Practices
1. **API-Kommunikation**
- Implementierung von Retry-Mechanismen
- Sorgfältiges Error Handling
- Ausführliches Logging aller Kommunikation
2. **Datenmanagement**
- Zentrale Datenhaltung in MariaDB
- Transaktionssicherheit bei kritischen Operationen
- Regelmäßige Datenvalidierung
3. **Prozessautomatisierung**
- Automatisierte Kundenanlage
- Automatische Statusaktualisierungen
- Systematisches Lieferschein-Management
## Lessons Learned
1. **API-Design**
- Gründliche API-Dokumentation ist essentiell
- Verständnis der verschiedenen Authentifizierungsmethoden
- Berücksichtigung von Rate Limits
2. **Datenvalidierung**
- Implementierung strenger Validierungsregeln
- Sorgfältige Handhabung von Sonderfällen
- Automatische Datenbereinigung
3. **Prozessoptimierung**
- Kontinuierliche Verbesserung der Automatisierung
- Regelmäßige Performance-Überprüfungen
- Proaktives Monitoring
## Fazit
Die erfolgreiche Integration zwischen Apparel Magic und Breuninger über TradeByte erforderte:
- Tiefes Verständnis beider Systeme
- Sorgfältige Implementierung der API-Kommunikation
- Robuste Fehlerbehandlung
- Kontinuierliches Monitoring
Die entwickelte Lösung verarbeitet erfolgreich Bestellungen und ermöglicht eine nahtlose Integration zwischen den Systemen. Besonders die automatisierte Kundenanlage und das Lieferschein-Management haben sich als effizienzsteigernd erwiesen.
@@ -0,0 +1,303 @@
---
title: "Fullstack-Entwicklung mit Python und React: Architektur unserer Zeiterfassungslösung"
date: "2024-02-09"
excerpt: "Eine technische Deep-Dive in die Implementierung einer modernen Zeiterfassungslösung"
category: "System Architecture"
coverImage: "/images/posts/fullstack-development-timetracking/cover.jpg"
tags: ["Python", "React", "TypeScript", "MSSQL", "System Design"]
---
# Fullstack-Entwicklung mit Python und React: Architektur unserer Zeiterfassungslösung
Die Entwicklung einer robusten Zeiterfassungslösung erfordert nicht nur technisches Know-how, sondern auch ein tiefes Verständnis für komplexe Geschäftsregeln und Benutzeranforderungen. In diesem Artikel teile ich unsere Erfahrungen bei der Implementierung einer modernen Fullstack-Zeiterfassungslösung mit Python und React.
## Systemarchitektur
### Frontend (Next.js + TypeScript)
Die Frontend-Architektur basiert auf Next.js mit TypeScript und folgt einem komponenten-basierten Ansatz:
```typescript
// types/TimeEntry.ts
interface TimeEntry {
id: number;
date: string;
checkIn: string;
checkOut: string | null;
userId: number;
status: 'complete' | 'incomplete';
}
// components/TimeEntryForm.tsx
const TimeEntryForm: React.FC<TimeEntryFormProps> = ({ onSubmit }) => {
const [entry, setEntry] = useState<TimeEntry>({
date: new Date().toISOString().split('T')[0],
checkIn: new Date().toLocaleTimeString(),
checkOut: null,
status: 'incomplete'
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateTimeEntry(entry)) return;
try {
await onSubmit(entry);
} catch (error) {
console.error('Error submitting time entry:', error);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<DateInput
value={entry.date}
onChange={(date) => setEntry({ ...entry, date })}
/>
<TimeInput
value={entry.checkIn}
onChange={(time) => setEntry({ ...entry, checkIn: time })}
/>
{/* Additional form elements */}
</form>
);
};
```
### Backend (Flask + MSSQL)
Das Backend verwendet Flask für die API und MSSQL für die Datenpersistenz:
```python
# models/time_entry.py
class TimeEntry(db.Model):
__tablename__ = 'Stundenzettel'
id = db.Column('ID', db.Decimal, primary_key=True)
personal_id = db.Column('Personal_ID', db.Decimal)
datum = db.Column('Datum', db.Date)
kommen = db.Column('Kommen', db.Time)
gehen = db.Column('Gehen', db.Time)
@validates('gehen')
def validate_checkout(self, key, value):
if value and value < self.kommen:
raise ValueError("Checkout time cannot be before checkin time")
return value
# routes/time_entries.py
@app.route('/api/time-entries', methods=['POST'])
@jwt_required
def create_time_entry():
data = request.get_json()
user_id = get_jwt_identity()
try:
validate_time_entry_creation(data, user_id)
entry = TimeEntry(
personal_id=user_id,
datum=data['date'],
kommen=data['checkIn'],
gehen=data.get('checkOut')
)
db.session.add(entry)
db.session.commit()
return jsonify(entry.to_dict()), 201
except ValidationError as e:
return jsonify({'error': str(e)}), 400
```
## Geschäftslogik-Implementierung
### Validierung von Zeiteinträgen
Die Validierungslogik stellt sicher, dass alle Geschäftsregeln eingehalten werden:
```python
def validate_time_entry_creation(data: dict, user_id: int) -> None:
"""Validates a new time entry according to business rules."""
# Check for existing incomplete entries
incomplete_entry = TimeEntry.query.filter_by(
personal_id=user_id,
gehen=None,
datum=data['date']
).first()
if incomplete_entry:
raise ValidationError("Cannot create new entry while incomplete entry exists")
# Check for time overlap with existing entries
overlapping_entry = TimeEntry.query.filter(
TimeEntry.personal_id == user_id,
TimeEntry.datum == data['date'],
TimeEntry.kommen <= data['checkIn'],
TimeEntry.gehen >= data['checkIn']
).first()
if overlapping_entry:
raise ValidationError("Time entry overlaps with existing entry")
```
### Frontend State Management
```typescript
// hooks/useTimeEntries.ts
const useTimeEntries = (date: string) => {
const [entries, setEntries] = useState<TimeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchEntries = async () => {
try {
setLoading(true);
const response = await fetch(`/api/time-entries?date=${date}`);
if (!response.ok) throw new Error("Failed to fetch entries");
const data = await response.json();
setEntries(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchEntries();
}, [date]);
return { entries, loading, error };
};
```
## Datenbankdesign
Das MSSQL-Datenbankschema ist auf Effizienz und Integrität ausgelegt:
```sql
CREATE TABLE dbo.Stundenzettel (
ID decimal NOT NULL PRIMARY KEY,
Personal_ID decimal,
Datum date,
Kommen time,
Gehen time,
xStatus int,
xBenutzer nvarchar(15),
xDatum datetime,
xVersion timestamp
);
CREATE INDEX idx_personal_datum
ON dbo.Stundenzettel(Personal_ID, Datum);
```
## Sicherheitsimplementierung
### JWT-Authentifizierung
```python
# auth/jwt_handler.py
from flask_jwt_extended import create_access_token
def authenticate_user(username: str, password: str) -> str:
user = Personal.query.filter_by(
Benutzername=username,
Passwort=password # In production, use proper password hashing
).first()
if not user:
raise AuthenticationError("Invalid credentials")
return create_access_token(identity=user.ID)
```
## Best Practices und Learnings
1. **Datenvalidierung auf mehreren Ebenen**
- Frontend-Validierung für sofortiges Feedback
- Backend-Validierung für Geschäftsregeln
- Datenbankconstraints für Datenintegrität
2. **Fehlerbehandlung**
- Strukturierte Fehlermeldungen
- Benutzerfreundliche Fehlermeldungen im Frontend
- Detailliertes Logging im Backend
3. **Performance-Optimierung**
- Indexierung kritischer Datenbankfelder
- Frontend-Caching von Zeiteinträgen
- Lazy Loading für historische Daten
## Herausforderungen und Lösungen
### 1. Zeitzonen-Handling
Eine besondere Herausforderung war die korrekte Behandlung von Zeitzonen:
```typescript
// utils/dateTime.ts
export const formatTimeForDisplay = (time: string): string => {
return new Date(`1970-01-01T${time}`).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
};
export const formatTimeForAPI = (time: string): string => {
return new Date(`1970-01-01T${time}`).toISOString().split('T')[1];
};
```
### 2. Concurrent Updates
Die Behandlung gleichzeitiger Updates erforderte spezielle Aufmerksamkeit:
```python
from sqlalchemy import and_, or_
def update_time_entry(entry_id: int, data: dict) -> TimeEntry:
entry = TimeEntry.query.filter_by(id=entry_id).with_for_update().first()
if not entry:
raise NotFoundError("Time entry not found")
# Optimistic locking using version field
if entry.xVersion != data['version']:
raise ConcurrencyError("Entry was modified by another user")
entry.gehen = data.get('checkOut')
db.session.commit()
return entry
```
### 3. Offline-Fähigkeit
Für die Offline-Funktionalität implementierten wir eine Service Worker-Strategie:
```typescript
// service-worker.ts
const CACHE_NAME = 'timetracking-v1';
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request).then(response => {
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
```
## Fazit
Die Entwicklung einer Zeiterfassungslösung erfordert sorgfältige Planung und Berücksichtigung zahlreicher Geschäftsregeln. Durch den Einsatz moderner Technologien wie Next.js, TypeScript und Flask konnten wir eine robuste und benutzerfreundliche Lösung implementieren.
Besonders wichtig waren:
- Strikte Typisierung mit TypeScript
- Umfassende Validierungslogik
- Effizientes Datenbankdesign
- Benutzerfreundliche Fehlerbehandlung
Die Lösung ist seit mehreren Monaten erfolgreich im Einsatz und verarbeitet täglich hunderte von Zeiteinträgen zuverlässig.
@@ -0,0 +1,231 @@
---
title: "RFID-Etiketten-Automatisierung: Von Datenbank zum Drucker"
date: "2024-02-09"
excerpt: "Eine detaillierte technische Analyse der Implementierung eines automatisierten RFID-Etikettendruck-Systems mit Python"
category: "System Integration"
coverImage: "/images/posts/rfid-automation/cover.jpg"
tags: ["RFID", "Python", "ZPL", "Automation", "Zebra Printer", "EPC"]
---
# RFID-Etiketten-Automatisierung: Von Datenbank zum Drucker
In modernen Logistik- und Retail-Umgebungen ist die effiziente und fehlerfreie Etikettierung von Produkten mit RFID-Tags von entscheidender Bedeutung. In diesem Artikel teile ich meine Erfahrungen bei der Implementierung einer automatisierten Lösung für die Generierung und den Druck von RFID-Etiketten mit Chipkodierung.
## Projekthintergrund
Die Hauptanforderungen an das System waren:
- Automatische Generierung von EPC-Codes (Electronic Product Code)
- Erstellung von ZPL-Code für Zebra-Drucker
- Direkte Netzwerkkommunikation mit dem Drucker
- Integration mit bestehenden Produktdaten
- Fehlerbehandlung und Logging
## Technische Umsetzung
### EPC-Code Generierung
Die EPC-Codes werden nach dem SGTIN-Standard (Serialized Global Trade Item Number) generiert:
```python
def generate_encoded_epc(company_prefix, indicator, item_ref, serial):
sgtin = SGTIN(company_prefix, indicator, item_ref, serial)
return sgtin.encode()
```
### ZPL-Template Management
Für die unterschiedlichen Etikettentypen wurden ZPL-Templates implementiert:
```python
def generate_zpl_code(artikelnr, description, ean, price, material, water_resistance, glass_type, encoded_epc):
formatted_price = f"{price:.2f}"
return f"""
CT~~CD,~CC^~CT~
^XA
~TA000
~JSN
^LT35
^MNW
^MTT
^PON
^PMN
^LH0,0
^JMA
^PR2,2
~SD23
^JUS
^LRN
^CI27
^PA0,1,1,0
^RS8,,,3
^XZ
^XA
^MMT
^PW413
^LL531
^LS-24
^FT150,57^A0N,33,33^FH\\^CI27^FD{artikelnr}^FS^CI27
^FT44,86^A0N,21,20^FH\\^CI27^FD{description}^FS^CI27
^FT130,141^A0N,50,51^FH\\^CI27^FD{formatted_price} €^FS^CI27
^FT167,175^A0N,21,20^FH\\^CI27^FD{material}^FS^CI27
^FT185,201^A0N,21,20^FH\\^CI27^FD{water_resistance}^FS^CI27
^FT155,227^A0N,21,20^FH\\^CI27^FD{glass_type}^FS^CI27
^BY3,2,113^FT73,420^BEN,,Y,N
^FH\\^FD{ean}^FS
^RFW,H,1,2,1^FD3000^FS
^RFW,H,2,12,1^FD{encoded_epc}^FS
^PQ1,0,1,Y
^XZ"""
```
### Netzwerkkommunikation mit dem Drucker
Die Kommunikation mit dem Zebra-Drucker erfolgt über TCP/IP:
```python
def send_to_printer(data, printer_ip="192.168.68.50", printer_port=9100):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((printer_ip, printer_port))
sock.sendall(data.encode('utf-8'))
logger.info("Daten erfolgreich an den Drucker gesendet")
except Exception as e:
logger.error(f"Fehler beim Senden der Daten an den Drucker: {e}")
```
### Datenprozessierung und Seriennummernverwaltung
Die Verwaltung von Seriennummern ist kritisch für die Eindeutigkeit der EPC-Codes:
```python
def initialize_serial_number():
global current_serial_number
try:
serial_log_content = SERIAL_NUMBER_LOG_PATH.read_text().strip()
current_serial_number = int(serial_log_content)
except (FileNotFoundError, ValueError):
current_serial_number = START_SERIAL_NUMBER
def get_next_serial_number():
global current_serial_number
current_serial_number += 1
return current_serial_number
def finalize_serial_number():
SERIAL_NUMBER_LOG_PATH.write_text(str(current_serial_number))
```
### Hauptprozess für die Etikettengenerierung
Der Gesamtprozess wird durch eine zentrale Funktion gesteuert:
```python
def process_data(row, output_path):
try:
serial_number = get_next_serial_number()
# EPC-Code generieren
encoded_epc = generate_encoded_epc(
company_prefix='811120403',
indicator='0',
item_ref='0',
serial=str(serial_number)
)
# Produktdaten extrahieren
artikelnr = str(row.get('Artikelnummer', '000000')).split('-')[0]
description = row.get('Description', 'No Description')
ean = str(row.get('EAN', '0000000000000'))
price = float(row.get('UVP', 0.00))
material = row.get('Material', 'Unknown')
water_resistance = str(row.get('Wasserdichte', 'N/A'))
glass_type = row.get('Glas', 'Unknown')
# ZPL-Code generieren
zpl_code = generate_zpl_code(
artikelnr, description, ean, price,
material, water_resistance, glass_type, encoded_epc
)
# ZPL-Code speichern
save_zpl_code(serial_number, zpl_code, output_path)
# Direkt drucken
send_to_printer(zpl_code)
except Exception as e:
logger.error(f"Fehler beim Verarbeiten der Datenzeile: {e}")
return row
```
## Implementierte Features
### 1. Robuste Fehlerbehandlung
Das System implementiert umfangreiche Fehlerbehandlung und Logging:
- Validierung der Eingabedaten
- Überprüfung der Netzwerkverbindung
- Persistenz von Seriennummern
- Detailliertes Logging aller Operationen
### 2. Konfigurierbarkeit
Die Lösung ist hochgradig konfigurierbar:
- Drucker-IP und Port einstellbar
- Anpassbare ZPL-Templates
- Flexible EPC-Code-Generierung
- Konfigurierbare Seriennummernbereiche
### 3. Skalierbarkeit
Das System wurde für Skalierbarkeit ausgelegt:
- Batch-Verarbeitung von Produktdaten
- Parallele Druckaufträge möglich
- Effizientes Ressourcenmanagement
- Modularer Code für einfache Erweiterbarkeit
## Best Practices
1. **Datenvalidierung**
- Strict typing für kritische Datenfelder
- Überprüfung von Pflichtfeldern
- Format- und Plausibilitätsprüfungen
2. **Fehlertoleranz**
- Automatische Wiederholungsversuche
- Graceful Degradation
- Detaillierte Fehlerprotokolle
3. **Wartbarkeit**
- Modularer Code-Aufbau
- Ausführliche Dokumentation
- Klare Trennung von Konfiguration und Code
## Lessons Learned
1. **ZPL-Spezifika**
- Genaue Kenntnis der ZPL-Spezifikationen notwendig
- Sorgfältige Validierung der generierten ZPL-Codes
- Regelmäßige Tests mit verschiedenen Druckermodellen
2. **RFID-Standards**
- Einhaltung der EPC-Standards kritisch
- Sorgfältige Verwaltung von Seriennummern
- Validierung der generierten EPC-Codes
3. **Netzwerkkommunikation**
- Robuste Fehlerbehandlung bei Netzwerkproblemen
- Timeouts und Wiederholungsversuche
- Pufferung von Druckaufträgen
## Fazit
Die implementierte Lösung ermöglicht eine effiziente und zuverlässige Automatisierung des RFID-Etikettierungsprozesses. Durch die Kombination von EPC-Code-Generierung, ZPL-Template-Management und direkter Druckerkommunikation wurde ein robustes System geschaffen, das sich in der Praxis bewährt hat.
Besonders wichtig für den Erfolg waren:
- Gründliche Planung der Systemarchitektur
- Umfassende Fehlerbehandlung
- Sorgfältige Dokumentation
- Regelmäßige Tests und Validierung
Die Lösung läuft seit mehreren Monaten stabil im Produktivbetrieb und verarbeitet täglich hunderte von Etiketten.
+46
View File
@@ -0,0 +1,46 @@
// mdxLoader.ts
// Definiere den Typ BlogPost, der der erwarteten Struktur entspricht
export interface BlogPost {
title: string;
date: string;
excerpt: string;
category?: string;
tags?: string[];
coverImage?: string;
content: any; // Passe den Typ hier bei Bedarf an
slug: string;
}
export async function getBlogPostBySlug(
slug: string,
language: string = 'de'
): Promise<BlogPost | null> {
try {
// Import der Übersetzungsdatei
const translationModule = await import(
`@/i18n/locales/${language}/blog/posts/${slug}.ts`
);
// Zugriff auf die richtige Struktur (passe diesen Zugriff an deine Datenstruktur an)
const postData = translationModule.default.posts.erp_integration;
if (!postData) {
console.warn(`Translation not found for ${slug} in ${language}`);
return null;
}
return {
title: postData.title,
date: postData.date,
excerpt: postData.excerpt,
category: postData.category,
tags: postData.tags,
coverImage: postData.coverImage,
content: postData.content,
slug
};
} catch (error) {
console.error(`Error loading blog post: ${slug}`, error);
return null;
}
}
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '../../../components/Card';
import { Input } from '../../../components/Input';
import { Textarea } from '../../../components/TextArea';
import { Label } from '../../../components/Label';
import { Button } from '../../../components/Button';
import { Alert, AlertDescription } from '../../../components/Alert';
import { Loader2, Send, CheckCircle2, Mail } from 'lucide-react';
import { trackContactFormSubmit } from '../../../services/gtm';
interface FormData {
name: string;
email: string;
message: string;
}
const ContactForm = () => {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState<Partial<FormData>>({});
const validateForm = () => {
const newErrors: Partial<FormData> = {};
if (!formData.name.trim()) {
newErrors.name = t('pages.contact.contactForm.errors.nameRequired');
}
if (!formData.email.trim()) {
newErrors.email = t('pages.contact.contactForm.errors.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('pages.contact.contactForm.errors.emailInvalid');
}
if (!formData.message.trim()) {
newErrors.message = t('pages.contact.contactForm.errors.messageRequired');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name as keyof FormData]) {
setErrors(prev => ({ ...prev, [name]: undefined }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
setSubmitStatus('idle');
try {
// Track form submission
trackContactFormSubmit({
name: formData.name,
email: formData.email,
subject: 'Contact Form'
});
// Simuliere einen API-Aufruf
await new Promise(resolve => setTimeout(resolve, 1500));
setSubmitStatus('success');
setFormData({ name: '', email: '', message: '' });
} catch {
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="h-full bg-zinc-800/30 border border-zinc-800">
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Mail className="h-5 w-5 text-zinc-400" />
<CardTitle className="text-xl font-semibold text-white">
{t('pages.contact.contactForm.title')}
</CardTitle>
</div>
<CardDescription className="text-zinc-400">
{t('pages.contact.contactForm.description')}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit}
className="space-y-6"
aria-label={t('pages.contact.contactForm.aria.form')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-zinc-300">
{t('pages.contact.contactForm.name.label')}
</Label>
<Input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.name.placeholder')}
className="h-12 bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.name}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-sm text-red-400 mt-1">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">
{t('pages.contact.contactForm.email.label')}
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.email.placeholder')}
className="h-12 bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.email}
disabled={isSubmitting}
/>
{errors.email && (
<p className="text-sm text-red-400 mt-1">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="message" className="text-zinc-300">
{t('pages.contact.contactForm.message.label')}
</Label>
<Textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.message.placeholder')}
rows={6}
className="bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.message}
disabled={isSubmitting}
/>
{errors.message && (
<p className="text-sm text-red-400 mt-1">{errors.message}</p>
)}
</div>
</div>
{submitStatus === 'success' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-label={t('pages.contact.contactForm.aria.successAlert')}
>
<div className="bg-zinc-900/50 border-zinc-700 text-zinc-300 p-3 rounded">
<Alert>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
{t('pages.contact.contactForm.successMessage')}
</AlertDescription>
</Alert>
</div>
</motion.div>
)}
{submitStatus === 'error' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-label={t('pages.contact.contactForm.aria.errorAlert')}
>
<div className="bg-red-900/20 border-red-900/50 text-red-400 p-3 rounded">
<Alert>
<AlertDescription>
{t('pages.contact.contactForm.errorMessage')}
</AlertDescription>
</Alert>
</div>
</motion.div>
)}
<Button
type="submit"
className="w-full h-12 bg-zinc-100 hover:bg-white text-zinc-900 font-medium transition-colors duration-300"
disabled={isSubmitting}
aria-label={
isSubmitting
? t('pages.contact.contactForm.aria.submitting')
: undefined
}
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Send className="h-5 w-5 mr-2" />
{t('pages.contact.contactForm.submit')}
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
};
export default ContactForm;
@@ -0,0 +1,137 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Phone, Mail, MapPin, ExternalLink, Building } from 'lucide-react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../components/Card';
interface ContactMethod {
icon: React.ReactNode;
label: string;
value: string;
href: string;
type: 'address' | 'phone' | 'email';
ariaLabel: string;
}
const ContactInfo = () => {
const { t } = useTranslation();
const contactMethods: ContactMethod[] = [
{
icon: <MapPin className="h-5 w-5" />,
label: t('pages.home.contactinfo.addressLabel'),
value: t('pages.home.contactinfo.address'),
href: '',
type: 'address',
ariaLabel: t('pages.home.contactinfo.aria.addressLink')
},
{
icon: <Phone className="h-5 w-5" />,
label: t('pages.home.contactinfo.phoneLabel'),
value: t('pages.home.contactinfo.phone'),
href: 'tel:+49 175 695 0979',
type: 'phone',
ariaLabel: t('pages.home.contactinfo.aria.phoneLink')
},
{
icon: <Mail className="h-5 w-5" />,
label: t('pages.home.contactinfo.emailLabel'),
value: t('pages.home.contactinfo.email'),
href: 'mailto:info@damjan-savic.com',
type: 'email',
ariaLabel: t('pages.home.contactinfo.aria.emailLink')
}
];
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
};
return (
<Card
className="h-full bg-zinc-800/30 border border-zinc-800"
aria-label={t('pages.home.contactinfo.aria.contactCard')}
>
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Building className="h-5 w-5 text-zinc-400" />
<CardTitle className="text-xl font-semibold text-white">
{t('pages.home.contactinfo.title')}
</CardTitle>
</div>
<CardDescription className="text-zinc-400">
{t('pages.home.contactinfo.description')}
</CardDescription>
</CardHeader>
<CardContent>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-4"
>
{contactMethods.map((method) => (
<motion.div key={method.type} variants={item}>
<Card className="group bg-zinc-900/50 border border-zinc-800
hover:bg-zinc-900/70 hover:border-zinc-700
transition-all duration-300">
<CardContent className="p-4">
<a
href={method.href}
target={method.type === 'address' ? '_blank' : undefined}
rel={method.type === 'address' ? 'noopener noreferrer' : undefined}
className="flex items-center gap-4"
aria-label={method.ariaLabel}
>
<div className="rounded-full p-2.5 bg-zinc-800/50 text-zinc-400
group-hover:bg-zinc-800 group-hover:text-white
transition-colors duration-300">
{method.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-400 mb-1">
{method.label}
</p>
<p className="text-base font-medium text-white truncate
group-hover:text-zinc-100">
{method.value}
</p>
</div>
<div className="self-center opacity-0 group-hover:opacity-100
transition-opacity duration-300">
<ExternalLink
className="h-4 w-4 text-zinc-400 group-hover:text-white"
aria-label={method.type === 'address' ? t('pages.home.contactinfo.aria.externalLink') : undefined}
/>
</div>
</a>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
<div className="mt-8 pt-6 border-t border-zinc-800 text-center">
<p className="text-sm text-zinc-400">
{t('pages.home.contactinfo.availabilityNote')}
</p>
</div>
</CardContent>
</Card>
);
};
export default ContactInfo;
+109
View File
@@ -0,0 +1,109 @@
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { MapPin, Phone, Mail } from 'lucide-react';
import PageTransition from '../../components/PageTransition';
import ContactForm from './components/ContactForm';
const ContactPage = () => {
const { t } = useTranslation();
return (
<PageTransition>
<div className="min-h-screen">
{/* Hero Section */}
<div className="py-24 text-center">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl md:text-5xl font-bold text-white mb-4"
>
{t('pages.contact.hero.title')}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-lg text-zinc-400 max-w-2xl mx-auto"
>
{t('pages.contact.hero.subtitle')}
</motion.p>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Contact Info */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="space-y-8"
>
<div>
<h2 className="text-2xl font-bold text-white mb-4">
{t('pages.contact.contactInfo.title')}
</h2>
<p className="text-zinc-400 mb-8">
{t('pages.contact.contactInfo.description')}
</p>
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<MapPin className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.address.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.address.value')}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Phone className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.phone.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.phone.value')}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Mail className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.email.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.email.value')}
</p>
</div>
</div>
</div>
<p className="text-sm text-zinc-500">
{t('pages.contact.contactInfo.availabilityNote')}
</p>
</motion.div>
{/* Contact Form */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<ContactForm />
</motion.div>
</div>
</div>
</div>
</PageTransition>
);
};
export default ContactPage;
+134
View File
@@ -0,0 +1,134 @@
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { MapPin, Briefcase, GraduationCap, Code, Award, Download } from 'lucide-react';
const About = () => {
const { t, i18n } = useTranslation();
// CV-Datei basierend auf der aktuellen Sprache
const getCvUrl = () => {
const lang = i18n.language;
if (lang === 'de') return '/damjan_savic_cv_de.pdf';
// Für alle anderen Sprachen (en, sr, etc.) englische Version verwenden
return '/damjan_savic_cv_en.pdf';
};
const highlights = [
{ icon: <MapPin className="w-4 h-4" />, label: "Köln, Deutschland" },
{ icon: <Briefcase className="w-4 h-4" />, label: "5+ Jahre Erfahrung" },
{ icon: <Code className="w-4 h-4" />, label: "Full-Stack Developer" },
{ icon: <Award className="w-4 h-4" />, label: "ERP Spezialist" }
];
return (
<section className="py-24 relative">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"
>
{/* Image Section - Simplified */}
<motion.div
className="relative"
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{/* Main Image Container */}
<div className="relative rounded-3xl overflow-hidden aspect-square bg-zinc-800/30 backdrop-blur-sm">
<img
src="/portrait.jpg"
alt={t('pages.home.about.image.alt')}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = 'data:image/svg+xml,%3Csvg width="400" height="400" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="400" height="400" fill="%2327272a"/%3E%3Ctext x="50%25" y="50%25" font-family="system-ui" font-size="24" fill="%2371717a" text-anchor="middle" dominant-baseline="middle"%3EDamjan Savić%3C/text%3E%3C/svg%3E';
}}
/>
</div>
</motion.div>
{/* Content Section */}
<div className="space-y-6">
{/* Title */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-2">
{t('pages.home.about.title')}
</h2>
<div className="h-1 w-20 bg-gradient-to-r from-zinc-600 to-zinc-800 rounded-full"></div>
</motion.div>
{/* Highlight Pills */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="flex flex-wrap gap-3"
>
{highlights.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
whileHover={{ scale: 1.05, y: -2 }}
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800/30 backdrop-blur-sm rounded-full border border-zinc-700/50 hover:border-zinc-600 transition-colors"
>
<span className="text-zinc-400">{item.icon}</span>
<span className="text-zinc-300 text-sm">{item.label}</span>
</motion.div>
))}
</motion.div>
{/* Main Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="space-y-4"
>
<p className="text-zinc-300 leading-relaxed text-lg">
{t('pages.home.about.content')}
</p>
</motion.div>
{/* Call to Action Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="flex flex-wrap gap-4 pt-4"
>
<motion.a
href="/about"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-6 py-3 bg-zinc-800/50 backdrop-blur-sm hover:bg-zinc-700/50 text-white rounded-xl font-medium transition-colors duration-300 flex items-center gap-2"
>
<GraduationCap className="w-4 h-4" />
{t('pages.home.about.buttons.learnMore')}
</motion.a>
<motion.a
href={getCvUrl()}
download
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-6 py-3 bg-transparent hover:bg-zinc-800/30 text-zinc-300 border border-zinc-700/50 rounded-xl font-medium transition-colors duration-300 flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('pages.home.about.buttons.downloadCV')}
</motion.a>
</motion.div>
</div>
</motion.div>
</div>
</section>
);
};
export default About;
@@ -0,0 +1,205 @@
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
const Experience = () => {
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(0);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Entfernen des 'translation.' Prefixes, da dies nicht in der Struktur vorhanden ist
const experiences = t('pages.home.experience.positions', {
returnObjects: true,
defaultValue: []
});
const itemsPerPage = window.innerWidth >= 768 ? 2 : 1;
const totalPages = Math.ceil((Array.isArray(experiences) ? experiences.length : 0) / itemsPerPage);
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.touches[0].clientX);
};
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > 50;
const isRightSwipe = distance < -50;
if (isLeftSwipe && currentPage < totalPages - 1) {
setCurrentPage(prev => prev + 1);
}
if (isRightSwipe && currentPage > 0) {
setCurrentPage(prev => prev - 1);
}
setTouchStart(null);
setTouchEnd(null);
};
const getCurrentPageItems = () => {
if (!Array.isArray(experiences)) return [];
const startIndex = currentPage * itemsPerPage;
return experiences.slice(startIndex, startIndex + itemsPerPage);
};
return (
<section className="py-12 md:py-24 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.8,
ease: [0.25, 0.46, 0.45, 0.94]
}}
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
<h2 className="text-3xl font-bold text-white text-center mb-12">
{t('pages.home.experience.title')}
</h2>
<div
ref={containerRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="relative"
>
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -100, scale: 0.95 }}
transition={{
duration: 0.5,
ease: [0.43, 0.13, 0.23, 0.96]
}}
className="grid grid-cols-1 md:grid-cols-2 gap-6"
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
{getCurrentPageItems().map((exp, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
delay: index * 0.15,
ease: [0.25, 0.46, 0.45, 0.94]
}}
whileHover={{
scale: 1.02,
y: -5,
transition: { duration: 0.3 }
}}
className="p-6 md:p-8 rounded-3xl bg-zinc-800/40 hover:bg-zinc-800/60 transition-all duration-300 hover:shadow-xl hover:shadow-zinc-900/50"
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
<h3 className="text-lg md:text-xl font-semibold text-white mb-1">
{exp.role}
</h3>
<h4 className="text-base md:text-lg text-zinc-300 mb-2">
{exp.company}
</h4>
<p className="text-sm text-zinc-400 mb-6">
{exp.period}
</p>
<ul className="space-y-3 md:space-y-4">
{exp.highlights?.map((highlight: string, hIndex: number) => (
<motion.li
key={hIndex}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.4,
delay: 0.3 + (hIndex * 0.1),
ease: "easeOut"
}}
className="text-zinc-400 text-sm leading-relaxed flex items-start"
>
<span className="text-white/40 mr-2 mt-0.5"></span>
<span>{highlight}</span>
</motion.li>
))}
</ul>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation Dots */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => currentPage > 0 && setCurrentPage(prev => prev - 1)}
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
disabled={currentPage === 0}
aria-label={t('pages.home.experience.navigation.prev')}
>
</button>
<div className="flex items-center gap-1">
{[...Array(totalPages)].map((_, index) => (
<button
key={index}
onClick={() => setCurrentPage(index)}
className="relative flex items-center justify-center transition-all duration-300"
style={{
width: '24px',
height: '24px',
minWidth: '24px',
minHeight: '24px'
}}
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
>
<span
className={`rounded-full transition-all duration-300 ${
index === currentPage
? 'bg-white'
: 'bg-zinc-600 hover:bg-zinc-400'
}`}
style={{
width: '8px',
height: '8px'
}}
/>
</button>
))}
</div>
<button
onClick={() => currentPage < totalPages - 1 && setCurrentPage(prev => prev + 1)}
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
disabled={currentPage === totalPages - 1}
aria-label={t('pages.home.experience.navigation.next')}
>
</button>
</div>
)}
</motion.div>
</div>
</section>
);
};
export default Experience;
@@ -0,0 +1,90 @@
"use client"
import { useTranslation } from 'react-i18next';
import { motion } from "framer-motion";
import { Code2, ShoppingBag, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/Card";
import { Badge } from "../../../components/Badge";
// Definiere den Typ für einen Expertise-Bereich
interface ExpertiseArea {
title: string;
description: string;
skills: string[];
}
// Typisierung für iconMap: Wir erwarten ein React-Komponenten-Typ, der optional eine className erhält
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
'ERP Integration': ShoppingBag,
'Entwicklung': Code2,
'E-Commerce': ShoppingBag,
'Digitales Marketing': TrendingUp,
// English mappings
'Development': Code2,
'Digital Marketing': TrendingUp
};
const Expertise = () => {
const { t } = useTranslation('home');
// Wir casten den Rückgabewert explizit zu ExpertiseArea[]
const expertiseAreas = t('expertise.areas', { returnObjects: true }) as ExpertiseArea[];
return (
<section className="py-12 sm:py-24 bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.h2
className="text-3xl sm:text-4xl font-bold text-foreground text-center mb-12"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{t('expertise.title')}
</motion.h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-8">
{expertiseAreas.map((area: ExpertiseArea, index: number) => {
const Icon = iconMap[area.title] || ShoppingBag;
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className="h-full bg-card hover:bg-accent transition-all duration-300 ease-in-out transform hover:-translate-y-2">
<CardHeader>
<div className="w-12 h-12 bg-primary rounded-full flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-primary-foreground" />
</div>
<CardTitle className="text-xl font-semibold text-foreground">
{area.title}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-muted-foreground mb-4">
{area.description}
</CardDescription>
<div className="flex flex-wrap gap-2">
{area.skills.map((skill: string, skillIndex: number) => (
<Badge
key={skillIndex}
variant="secondary"
className="bg-secondary text-secondary-foreground hover:bg-primary hover:text-primary-foreground transition-colors duration-200"
>
{skill}
</Badge>
))}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</div>
</section>
);
};
export default Expertise;
+158
View File
@@ -0,0 +1,158 @@
import { useTranslation } from 'react-i18next';
import { Linkedin, Github } from 'lucide-react';
const Hero = () => {
const { t } = useTranslation();
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<section id="home" className="relative min-h-screen text-white overflow-hidden">
{/* Concentric Circles Background */}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center" style={{ zIndex: 2 }}>
{[1, 2, 3].map((index) => (
<div
key={index}
className="absolute rounded-full border border-zinc-800/30"
style={{
width: `${index * 30}%`,
height: `${index * 30}%`,
opacity: 0.1,
transform: 'translateZ(0)'
}}
/>
))}
</div>
{/* Social Links Bar */}
<div className="absolute top-20 left-0 right-0 px-4 sm:px-8" style={{ zIndex: 40 }}>
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div className="flex gap-4">
<a
href="https://www.linkedin.com/in/damjan-savi%C4%87-720288127/"
target="_blank"
rel="noopener noreferrer"
className="group text-zinc-400 hover:text-white transition-colors"
aria-label="LinkedIn"
>
<Linkedin className="transform transition-transform group-hover:scale-110" size={20} />
</a>
<a
href="https://github.com/damjan1996"
target="_blank"
rel="noopener noreferrer"
className="group text-zinc-400 hover:text-white transition-colors"
aria-label="GitHub"
>
<Github className="transform transition-transform group-hover:scale-110" size={20} />
</a>
</div>
<a
href="mailto:info@damjan-savic.com"
className="text-sm text-zinc-400 hover:text-white transition-colors uppercase tracking-wider"
>
{t('pages.home.hero.cta')}
</a>
</div>
</div>
{/* Main Content */}
<div className="flex flex-col items-center justify-start min-h-screen px-4 pt-32 relative" style={{ zIndex: 30 }}>
{/* Profile Image */}
<div
className="w-56 h-56 sm:w-72 sm:h-72 mb-8 rounded-full overflow-hidden border-2 border-zinc-800"
style={{ transform: 'translateZ(0)' }}
>
<picture>
<source
srcSet="/portrait-224.webp 224w, /portrait-448.webp 448w, /portrait-288.webp 288w, /portrait-576.webp 576w"
sizes="(max-width: 640px) 224px, 288px"
type="image/webp"
/>
<source
srcSet="/portrait-224.jpg 224w, /portrait-288.jpg 288w"
sizes="(max-width: 640px) 224px, 288px"
type="image/jpeg"
/>
<img
src="/portrait-288.jpg"
alt={t('pages.home.hero.image.alt')}
className="w-full h-full object-cover"
loading="eager"
fetchPriority="high"
width={288}
height={288}
decoding="sync"
/>
</picture>
</div>
{/* Title and Name */}
<p className="text-zinc-400 text-sm sm:text-base tracking-wider mb-2">
{t('pages.home.hero.title')}
</p>
<h1 className="text-4xl sm:text-5xl font-bold mb-3 flex items-center">
{t('pages.home.hero.name')}<span className="animate-pulse ml-1">|</span>
</h1>
<p className="text-zinc-300 text-sm sm:text-base max-w-xl text-center mb-2 px-4">
{t('pages.home.hero.description')}
</p>
<p className="text-zinc-400 text-xs sm:text-sm mb-8">
{t('pages.home.hero.currentRole')}
</p>
{/* Navigation */}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 text-white text-base sm:text-lg z-20">
<button
onClick={() => scrollToSection('experience')}
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
>
<span className="relative z-10">{t('pages.home.hero.navigation.experience')}</span>
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
</button>
<button
onClick={() => scrollToSection('skills')}
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
>
<span className="relative z-10">{t('pages.home.hero.navigation.skills')}</span>
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
</button>
<button
onClick={() => scrollToSection('projects')}
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
>
<span className="relative z-10">{t('pages.home.hero.navigation.projects')}</span>
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
</button>
<button
onClick={() => scrollToSection('about')}
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
>
<span className="relative z-10">{t('pages.home.hero.navigation.about')}</span>
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
</button>
</div>
</div>
{/* Contact Button Bottom */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2" style={{ zIndex: 40 }}>
<a
href="mailto:info@damjan-savic.com"
className="w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors"
aria-label={t('pages.home.hero.social.getInTouch')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M0 3v18h24v-18h-24zm21.518 2l-9.518 7.713-9.518-7.713h19.036zm-19.518 14v-11.817l10 8.104 10-8.104v11.817h-20z"/>
</svg>
</a>
</div>
</section>
);
};
export default Hero;
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import PortfolioCard from '../../portfolio/components/PortfolioCard';
import { getAllProjects } from '../../portfolio/utils';
import type { Project } from '../../portfolio/utils';
// Erweiterter Typ: Wir gehen davon aus, dass jedes Project über einen 'slug' verfügt.
// Falls doch ein 'id'-Feld existieren sollte, wird es beibehalten.
interface ProjectWithId extends Project {
id: string;
}
const Projects = () => {
const { t } = useTranslation();
const [projects, setProjects] = useState<ProjectWithId[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProjects = async () => {
try {
const projectsData = await getAllProjects();
// Ergänze das fehlende 'id'-Feld (als Fallback verwenden wir hier den 'slug')
const projectsWithId: ProjectWithId[] = projectsData.map((project) => ({
...project,
id: project.slug,
}));
// Nur die ersten 3 Projekte für die Homepage
setProjects(projectsWithId.slice(0, 3));
} catch (error) {
console.error(t('pages.home.projects.error.loading'), error);
} finally {
setLoading(false);
}
};
loadProjects();
}, [t]);
if (loading) {
return (
<section className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-center items-center min-h-[300px] gap-3">
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
<span className="text-zinc-400">
{t('pages.home.projects.loading')}
</span>
</div>
</div>
</section>
);
}
return (
<section className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex justify-between items-center mb-12">
<h2 className="text-3xl font-bold text-zinc-100">
{t('pages.home.projects.title')}
</h2>
<Link
to="/portfolio"
className="text-zinc-400 hover:text-white transition-colors duration-300"
aria-label={t('pages.home.projects.viewAll')}
>
{t('pages.home.projects.viewAll')}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<PortfolioCard project={project} />
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default Projects;
+182
View File
@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { Database, Server, Code2, Bot, Workflow, FileCode2 } from 'lucide-react';
interface Skill {
name: string;
level: number;
description: string;
}
const iconMap: { [key: string]: React.ReactNode } = {
'AI & LLMs': <Bot className="w-8 h-8" />,
'Automation': <Workflow className="w-8 h-8" />,
'Automatizacija': <Workflow className="w-8 h-8" />,
'Python': <Code2 className="w-8 h-8" />,
'TypeScript': <FileCode2 className="w-8 h-8" />,
'Datenbank': <Database className="w-8 h-8" />,
'Database': <Database className="w-8 h-8" />,
'Baze podataka': <Database className="w-8 h-8" />,
'DevOps': <Server className="w-8 h-8" />
};
const Skills = () => {
const { t } = useTranslation();
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
useEffect(() => {
try {
// Type Assertion: Wir gehen davon aus, dass die Übersetzungsobjekte dem Skill-Interface entsprechen.
const translatedSkills = t('pages.home.skills.skills', {
returnObjects: true,
defaultValue: []
}) as Skill[];
if (Array.isArray(translatedSkills)) {
setSkills(translatedSkills);
} else {
console.error('Skills translation is not an array');
setSkills([]);
}
} catch (error) {
console.error('Error loading skills:', error);
setSkills([]);
}
}, [t]);
if (skills.length === 0) {
return <div>Loading skills...</div>;
}
return (
<section className="py-24 relative">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold text-white mb-12">
{t('pages.home.skills.title')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Skills Progress Bars */}
<div className="space-y-6">
{skills.map((skill) => (
<motion.div
key={skill.name}
className="space-y-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 0.1 }}
>
<div className="flex justify-between items-center">
<span className="text-white text-sm font-medium">
{skill.name}
</span>
<span className="text-white text-sm">
{skill.level}%
</span>
</div>
<div
className="h-2 bg-zinc-800/50 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={skill.level}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t('pages.home.skills.aria.skillLevel')}
>
<motion.div
className="h-full rounded-full bg-gradient-to-r from-zinc-600 to-zinc-500"
initial={{ width: 0 }}
animate={{
width: `${skill.level}%`
}}
transition={{
width: { duration: 1, delay: skills.indexOf(skill) * 0.1 }
}}
style={{
transform: 'translateZ(0)',
willChange: 'width'
}}
/>
</div>
</motion.div>
))}
</div>
{/* Skills Icons Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{skills.map((skill) => (
<motion.div
key={skill.name}
className="relative"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 0.1 }}
>
<motion.div
className={`relative p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors duration-300 hover:bg-zinc-700/50 ${
selectedSkill === skill.name ? 'ring-2 ring-zinc-500 z-20' : ''
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
onHoverStart={() => setSelectedSkill(skill.name)}
onHoverEnd={() => setSelectedSkill(null)}
role="button"
aria-pressed={selectedSkill === skill.name}
aria-label={t('pages.home.skills.aria.selectSkill')}
style={{
transform: 'translateZ(0)',
willChange: 'transform',
position: 'relative',
zIndex: selectedSkill === skill.name ? 20 : 1
}}
>
<motion.div
className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}
animate={{
rotate: selectedSkill === skill.name ? [0, -10, 10, 0] : 0,
scale: selectedSkill === skill.name ? 1.1 : 1
}}
transition={{ duration: 0.4 }}
>
{iconMap[skill.name]}
</motion.div>
<span className="text-white text-sm text-center">
{skill.name}
</span>
<AnimatePresence>
{selectedSkill === skill.name && (
<motion.div
className="absolute left-1/2 top-0 -translate-x-1/2 bg-zinc-800/80 backdrop-blur-sm rounded-lg px-4 py-2 text-zinc-300 text-xs text-center shadow-xl pointer-events-none"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.15 }}
style={{
whiteSpace: 'nowrap',
transform: 'translate(-50%, calc(-100% - 12px))',
zIndex: 999
}}
>
{skill.description}
<div className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-800"></div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default Skills;
+115
View File
@@ -0,0 +1,115 @@
import SEO from '../../components/SEO';
import Hero from './components/Hero';
import Experience from './components/Experience';
import Skills from './components/Skills';
import Projects from './components/Projects';
import About from './components/About';
import { FAQSection } from '../../components/FAQSection';
import { LocalizedWebsiteSchema, LocalizedPersonSchema } from '../../components/schemas/LocalizedSchemas';
import { LocalBusinessSchema } from '../../components/schemas/LocalBusinessSchema';
import { ServiceSchema } from '../../components/schemas/ServiceSchema';
const HomePage = () => {
const schema = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
'@id': 'https://damjan-savic.com/#profilepage',
name: 'Damjan Savić - AI & Automation Specialist',
description:
'Damjan Savić ist AI & Automation Specialist. Spezialisiert auf KI-Agenten, Voice AI, Prozessautomatisierung mit n8n, und Fullstack Development.',
mainEntity: {
'@type': 'Person',
name: 'Damjan Savić',
jobTitle: ['AI & Automation Specialist', 'Process Automation Specialist', 'Fullstack Developer'],
description:
'Damjan Savić entwickelt KI-Agenten und Automatisierungslösungen. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten.',
knowsLanguage: [
'English',
'German',
'Serbian',
'French',
'Spanish',
'Russian'
],
hasOccupation: {
'@type': 'Occupation',
name: 'AI & Automation Specialist',
skills: [
'AI Agents Development',
'Voice AI (Vapi)',
'Process Automation (n8n, Zapier)',
'Python Development',
'TypeScript & React',
'Next.js',
'GPT-4 & Claude API Integration',
'Web Scraping',
'Supabase & PostgreSQL',
'WebSocket & Real-time Applications'
]
},
worksFor: {
'@type': 'Organization',
name: 'Everlast Consulting GmbH',
description: 'Process Automation & AI Solutions',
location: 'Remote'
},
address: {
'@type': 'PostalAddress',
addressLocality: 'Bergisch Gladbach',
addressRegion: 'Nordrhein-Westfalen',
addressCountry: 'Deutschland'
}
}
};
const metaDescription =
'Damjan Savić - AI & Automation Specialist. Entwicklung von KI-Agenten und Automatisierungslösungen mit n8n, Vapi Voice AI, ' +
'GPT-4 und Claude API. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten. TypeScript, Python, Next.js.';
return (
<>
<SEO
title="Damjan Savić - AI & Automation Specialist | KI-Agenten, Voice AI & Prozessautomatisierung"
description={metaDescription}
schema={schema}
/>
<LocalizedWebsiteSchema />
<LocalizedPersonSchema />
<LocalBusinessSchema />
<ServiceSchema />
<main className="min-h-screen">
{/* Hero Section */}
<div id="home">
<Hero />
</div>
{/* Experience Section */}
<div id="experience">
<Experience />
</div>
{/* Skills Section */}
<div id="skills">
<Skills />
</div>
{/* Projects Section */}
<div id="projects">
<Projects />
</div>
{/* About Section */}
<div id="about">
<About />
</div>
{/* FAQ Section */}
<div id="faq" className="py-16">
<FAQSection />
</div>
</main>
</>
);
};
export default HomePage;
+417
View File
@@ -0,0 +1,417 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Calendar, Building2, Clock, ArrowLeft, Tag, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Import project data direkt
import deAiDataReader from '../../i18n/locales/de/portfolio/projects/ai-data-reader';
import enAiDataReader from '../../i18n/locales/en/portfolio/projects/ai-data-reader';
import srAiDataReader from '../../i18n/locales/sr/portfolio/projects/ai-data-reader';
import deKamenpro from '../../i18n/locales/de/portfolio/projects/kamenpro';
import enKamenpro from '../../i18n/locales/en/portfolio/projects/kamenpro';
import srKamenpro from '../../i18n/locales/sr/portfolio/projects/kamenpro';
import deSmartWarehouse from '../../i18n/locales/de/portfolio/projects/smart-warehouse';
import enSmartWarehouse from '../../i18n/locales/en/portfolio/projects/smart-warehouse';
import srSmartWarehouse from '../../i18n/locales/sr/portfolio/projects/smart-warehouse';
import dePowerPlatform from '../../i18n/locales/de/portfolio/projects/power-platform-governance';
import enPowerPlatform from '../../i18n/locales/en/portfolio/projects/power-platform-governance';
import srPowerPlatform from '../../i18n/locales/sr/portfolio/projects/power-platform-governance';
import deAutomatedAdCreatives from '../../i18n/locales/de/portfolio/projects/automated-ad-creatives';
import enAutomatedAdCreatives from '../../i18n/locales/en/portfolio/projects/automated-ad-creatives';
import srAutomatedAdCreatives from '../../i18n/locales/sr/portfolio/projects/automated-ad-creatives';
import deWebsiteMitKi from '../../i18n/locales/de/portfolio/projects/website-mit-ki';
import enWebsiteMitKi from '../../i18n/locales/en/portfolio/projects/website-mit-ki';
import srWebsiteMitKi from '../../i18n/locales/sr/portfolio/projects/website-mit-ki';
import deAiMusicProduction from '../../i18n/locales/de/portfolio/projects/ai-music-production';
import enAiMusicProduction from '../../i18n/locales/en/portfolio/projects/ai-music-production';
import srAiMusicProduction from '../../i18n/locales/sr/portfolio/projects/ai-music-production';
import deCursorIde from '../../i18n/locales/de/portfolio/projects/cursor-ide';
import enCursorIde from '../../i18n/locales/en/portfolio/projects/cursor-ide';
import srCursorIde from '../../i18n/locales/sr/portfolio/projects/cursor-ide';
// Interfaces für den Projektinhalt
interface ProjectMeta {
date?: string;
client?: string;
duration?: string;
title: string;
description: string;
technologies?: string[];
videoUrl?: string;
}
interface ProjectContent {
intro: string;
challenge: {
title: string;
description: string;
points?: string[];
};
solution: {
title: string;
description: string;
content?: string;
points?: string[];
};
technical?: {
title: string;
description: string;
points?: string[];
code?: string;
};
implementation?: {
title: string;
description: string;
points?: string[];
};
results: {
title: string;
description: string;
points?: string[];
};
conclusion?: string;
}
export interface TranslatedProject {
meta: ProjectMeta;
content: ProjectContent;
}
// Typalias für die Übersetzungen, ohne any zu verwenden
type ProjectTranslations = {
[slug: string]: {
[lang: string]: TranslatedProject;
};
};
// Map der verfügbaren Übersetzungen mit explizitem Typ
const projectTranslations: ProjectTranslations = {
'ai-data-reader': {
de: deAiDataReader,
en: enAiDataReader,
sr: srAiDataReader,
},
'kamenpro': {
de: deKamenpro,
en: enKamenpro,
sr: srKamenpro,
},
'smart-warehouse': {
de: deSmartWarehouse,
en: enSmartWarehouse,
sr: srSmartWarehouse,
},
'power-platform-governance': {
de: dePowerPlatform,
en: enPowerPlatform,
sr: srPowerPlatform,
},
'automated-ad-creatives': {
de: deAutomatedAdCreatives,
en: enAutomatedAdCreatives,
sr: srAutomatedAdCreatives,
},
'website-mit-ki': {
de: deWebsiteMitKi,
en: enWebsiteMitKi,
sr: srWebsiteMitKi,
},
'ai-music-production': {
de: deAiMusicProduction,
en: enAiMusicProduction,
sr: srAiMusicProduction,
},
'cursor-ide': {
de: deCursorIde,
en: enCursorIde,
sr: srCursorIde,
},
};
const TranslatedProjectPage = () => {
// Typisiere useParams, damit slug als string erwartet wird
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation();
const [project, setProject] = useState<TranslatedProject | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const loadProject = () => {
setLoading(true);
if (!slug) {
setError(new Error("No project slug provided"));
setLoading(false);
return;
}
try {
// Hole die Übersetzungen für das aktuelle Projekt
const projectData = projectTranslations[slug];
if (!projectData) {
throw new Error(`Project ${slug} not found`);
}
// Versuche, die Übersetzung für die aktuelle Sprache zu laden, ansonsten Fallback auf Englisch
const translation: TranslatedProject = projectData[i18n.language] || projectData.en;
if (!translation) {
throw new Error('No translation available');
}
setProject(translation);
setError(null);
} catch (err) {
console.error('Error loading project:', err);
setError(err as Error);
} finally {
setLoading(false);
}
};
loadProject();
}, [slug, i18n.language]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center space-y-4"
>
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
<p className="text-zinc-400 text-sm">
{t('pages.portfolio.project.loading')}
</p>
</motion.div>
</div>
);
}
if (error || !project) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center space-y-6 px-4 text-center"
>
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">
{t('pages.portfolio.project.notFound.title')}
</h3>
<p className="text-zinc-400 text-sm max-w-md">
{(error && error.message) || t('pages.portfolio.project.notFound.description')}
</p>
</div>
<Link
to="/portfolio"
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{t('pages.portfolio.project.backToPortfolio')}</span>
</Link>
</motion.div>
</div>
);
}
return (
<main className="min-h-screen">
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
{/* Back Navigation */}
<div className="mb-8">
<Link
to="/portfolio"
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{t('pages.portfolio.project.backToPortfolio')}</span>
</Link>
</div>
{/* Project Header */}
<div className="mb-12">
<div className="flex flex-wrap items-center gap-4 mb-6 text-zinc-400">
{project.meta.date && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<time>{project.meta.date}</time>
</div>
)}
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
<span>{project.meta.client}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>{project.meta.duration}</span>
</div>
</div>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-8">
{project.meta.title}
</h1>
<p className="text-xl text-zinc-400">
{project.meta.description}
</p>
</div>
{/* Cover Video or Image */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-12"
>
{project.meta.videoUrl ? (
<div className="relative aspect-video rounded-lg overflow-hidden bg-zinc-800">
<iframe
src={`${project.meta.videoUrl}/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0`}
className="absolute top-0 left-0 w-full h-full border-none"
allowFullScreen
loading="lazy"
/>
</div>
) : (
<div className="relative aspect-video rounded-lg overflow-hidden bg-zinc-800">
<img
src={`/images/projects/${slug}/cover.jpg`}
alt={project.meta.title}
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
console.error('Image failed to load:', e.currentTarget.src);
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900/50 to-transparent opacity-50" />
</div>
)}
</motion.div>
{/* Project Content */}
<div className="prose prose-invert max-w-none">
{/* Intro */}
<div className="mb-12 p-6 bg-zinc-800/30 rounded-lg border border-zinc-800">
<p className="text-lg text-zinc-300 m-0">
{project.content.intro}
</p>
</div>
{/* Challenge Section */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">{project.content.challenge.title}</h2>
<p className="text-zinc-300 mb-4">{project.content.challenge.description}</p>
{project.content.challenge.points && (
<ul className="space-y-2">
{project.content.challenge.points.map((point: string, index: number) => (
<li key={index} className="text-zinc-300">{point}</li>
))}
</ul>
)}
</section>
{/* Solution Section */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">{project.content.solution.title}</h2>
<p className="text-zinc-300 mb-4">{project.content.solution.description}</p>
{project.content.solution.content && (
<p className="text-zinc-300 mb-4">{project.content.solution.content}</p>
)}
{project.content.solution.points && (
<ul className="space-y-2">
{project.content.solution.points.map((point: string, index: number) => (
<li key={index} className="text-zinc-300">{point}</li>
))}
</ul>
)}
</section>
{/* Technical Section */}
{project.content.technical && (
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">{project.content.technical.title}</h2>
<p className="text-zinc-300 mb-4">{project.content.technical.description}</p>
{project.content.technical.points && (
<ul className="space-y-2">
{project.content.technical.points.map((point: string, index: number) => (
<li key={index} className="text-zinc-300">{point}</li>
))}
</ul>
)}
{project.content.technical.code && (
<pre className="bg-zinc-800/50 p-4 rounded-lg overflow-x-auto">
<code className="text-sm">{project.content.technical.code}</code>
</pre>
)}
</section>
)}
{/* Implementation Section */}
{project.content.implementation && (
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">{project.content.implementation.title}</h2>
<p className="text-zinc-300 mb-4">{project.content.implementation.description}</p>
{project.content.implementation.points && (
<ul className="space-y-2">
{project.content.implementation.points.map((point: string, index: number) => (
<li key={index} className="text-zinc-300">{point}</li>
))}
</ul>
)}
</section>
)}
{/* Results Section */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">{project.content.results.title}</h2>
<p className="text-zinc-300 mb-4">{project.content.results.description}</p>
{project.content.results.points && (
<ul className="space-y-2">
{project.content.results.points.map((point: string, index: number) => (
<li key={index} className="text-zinc-300">{point}</li>
))}
</ul>
)}
</section>
{/* Conclusion */}
{project.content.conclusion && (
<section className="mb-12 p-6 bg-zinc-800/30 rounded-lg border border-zinc-800">
<p className="text-lg text-zinc-300 m-0">
{project.content.conclusion}
</p>
</section>
)}
</div>
{/* Technologies */}
{project.meta.technologies && (
<div className="mt-12 pt-6 border-t border-zinc-800">
<div className="flex items-center gap-4">
<Tag className="h-4 w-4 text-zinc-400" />
<div className="flex flex-wrap gap-2">
{project.meta.technologies.map((tech: string, index: number) => (
<span
key={index}
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
>
{tech}
</span>
))}
</div>
</div>
</div>
)}
</article>
</main>
);
};
export default TranslatedProjectPage;
+86
View File
@@ -0,0 +1,86 @@
// pages/portfolio/projectsData.ts
import { type ComponentType } from 'react';
export interface Project {
slug: string;
title: string;
description: string;
image: string;
client: string;
duration: string;
technologies: string[];
overview: string;
challenges: string[];
solutions: string[];
results: string[];
// Optionale Eigenschaften, die in den Filtern verwendet werden
featured?: boolean;
category?: string;
}
export interface ProjectWithContent extends Project {
content: ComponentType;
}
/**
* Lädt das Projekt mit dem übergebenen Slug.
* Wird synchron geladen, da wir eager loading mit import.meta.glob verwenden.
*/
export const getProjectBySlug = (slug: string | undefined): Project | null => {
if (!slug) return null;
try {
// Cast des Ergebnisses zu einem Record, dessen Module die Eigenschaft "default" haben.
const projectFiles = import.meta.glob('./projects/*.mdx', { eager: true }) as Record<
string,
{ default: Project }
>;
const projectModule = projectFiles[`./projects/${slug}.mdx`];
if (projectModule) {
return projectModule.default;
}
return null;
} catch (error) {
console.error('Failed to load project:', error);
return null;
}
};
/**
* Lädt alle Projekte aus dem Verzeichnis projects.
*/
export const getAllProjects = (): Project[] => {
const projectFiles = import.meta.glob('./projects/*.mdx', { eager: true }) as Record<
string,
{ default: Project }
>;
const projects: Project[] = [];
for (const path in projectFiles) {
try {
const projectModule = projectFiles[path];
const project = projectModule.default;
if (project) {
projects.push(project);
}
} catch (error) {
console.error(`Failed to load project from ${path}:`, error);
}
}
return projects;
};
/**
* Gibt alle als featured markierten Projekte zurück.
*/
export const getFeaturedProjects = (): Project[] => {
return getAllProjects().filter((project) => project.featured);
};
/**
* Gibt alle Projekte einer bestimmten Kategorie zurück.
*/
export const getProjectsByCategory = (category: string): Project[] => {
return getAllProjects().filter((project) => project.category === category);
};
@@ -0,0 +1,16 @@
// components/TranslatedMDX.tsx
import React from 'react';
interface TranslatedMDXProps {
content: React.ComponentType;
}
const TranslatedMDX: React.FC<TranslatedMDXProps> = ({ content: Content }) => {
return (
<div className="mdx-content">
<Content />
</div>
);
};
export default TranslatedMDX;
+61
View File
@@ -0,0 +1,61 @@
// components/ProjectContentTemplate.tsx
import React from 'react';
export interface TranslatedContent {
meta: {
title: string;
description: string;
date: string;
client: string;
duration: string;
technologies: string[];
};
content: {
intro: string;
challenge: {
title: string;
description: string;
points?: string[];
};
solution: {
title: string;
description: string;
content?: string;
points?: string[];
};
technical: {
title: string;
description: string;
points?: string[];
code?: string;
};
implementation: {
title: string;
description: string;
points?: string[];
};
results: {
title: string;
description: string;
points?: string[];
};
conclusion?: string;
};
}
interface ProjectContentTemplateProps {
content: TranslatedContent;
}
const ProjectContentTemplate: React.FC<ProjectContentTemplateProps> = ({ content }) => {
return (
<div>
{/* Beispielhaftes Rendering */}
<h1>{content.meta.title}</h1>
<p>{content.meta.description}</p>
{/* Weitere Darstellung des Inhalts */}
</div>
);
};
export default ProjectContentTemplate;
@@ -0,0 +1,169 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ExternalLink, Calendar, Tag } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trackProjectClick } from '../../../services/gtm';
interface Project {
id: string;
title: string;
description: string;
tags: string[];
slug: string;
date?: string;
excerpt?: string;
technologies?: string[];
category?: string;
}
interface PortfolioCardProps {
project: Project;
}
const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
const { t } = useTranslation();
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
const basePath = `/images/projects/${project.slug}`;
const imagePath = `${basePath}/cover.jpg`;
const srcSetJpg = `${basePath}/cover-200w.jpg 200w, ${basePath}/cover-300w.jpg 300w, ${basePath}/cover-400w.jpg 400w, ${basePath}/cover-600w.jpg 600w, ${basePath}/cover-800w.jpg 800w, ${basePath}/cover-1200w.jpg 1200w`;
const srcSetWebP = `${basePath}/cover-200w.webp 200w, ${basePath}/cover-300w.webp 300w, ${basePath}/cover-400w.webp 400w, ${basePath}/cover-600w.webp 600w, ${basePath}/cover-800w.webp 800w, ${basePath}/cover-1200w.webp 1200w`;
// Mobile: ~350px, Tablet: ~400px, Desktop 2-col: ~550px, Desktop 3-col: ~400px
const imageSizes = "(max-width: 640px) 350px, (max-width: 768px) 400px, (max-width: 1024px) 550px, 400px";
// Verwende technologies anstelle von tags wenn verfügbar
const displayTags = project.technologies || project.tags;
const handleClick = () => {
trackProjectClick(project.title, project.category);
};
return (
<Link
to={`/portfolio/${project.slug}`}
onClick={handleClick}
className="group relative block bg-zinc-900/50 backdrop-blur-sm
rounded-xl shadow-lg hover:shadow-2xl
ring-1 ring-zinc-800 hover:ring-zinc-700
transition-all duration-500 focus:outline-none focus:ring-2
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
transform hover:-translate-y-1"
>
<motion.div
variants={itemVariants}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-xl"
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
{/* Image Container */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<picture>
<source
srcSet={srcSetWebP}
sizes={imageSizes}
type="image/webp"
/>
<source
srcSet={srcSetJpg}
sizes={imageSizes}
type="image/jpeg"
/>
<img
src={imagePath}
alt={project.title}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
group-hover:scale-110"
loading="lazy"
width={400}
height={225}
decoding="async"
style={{ transformOrigin: 'center center' }}
/>
</picture>
</div>
{/* Content - with relative positioning to overlap the gradient */}
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
{/* Project Info */}
<div className="flex items-center gap-4 mb-4 text-zinc-500">
{project.date && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>{project.date}</span>
</div>
)}
{displayTags && displayTags.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" />
<span>
{t('portfolio.ui.technologiesCount', {
count: displayTags.length,
defaultValue: '{{count}} Technologies'
})}
</span>
</div>
)}
</div>
{/* Title & Description */}
<h3 className="text-xl font-semibold text-white mb-3
group-hover:text-zinc-100 transition-colors duration-300">
{project.title}
</h3>
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
group-hover:text-zinc-300 transition-colors duration-300">
{project.excerpt || project.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{displayTags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-zinc-800/50
text-sm text-zinc-400 rounded-full
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
transition-all duration-300"
>
{tag}
</span>
))}
{displayTags.length > 3 && (
<span className="text-sm text-zinc-600">
{t('portfolio.ui.moreTechnologies', {
count: displayTags.length - 3,
defaultValue: '+{{count}} more'
})}
</span>
)}
</div>
{/* Link Icon */}
<div className="absolute top-6 right-6 p-2.5 rounded-full
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
opacity-0 group-hover:opacity-100
transform translate-y-2 group-hover:translate-y-0
transition-all duration-300">
<ExternalLink className="h-4 w-4 text-zinc-400" />
</div>
</div>
{/* Subtle Hover Border */}
<div className="absolute inset-0 rounded-xl pointer-events-none
ring-1 ring-zinc-600/0 group-hover:ring-zinc-600/30
transition-all duration-500" />
</motion.div>
</Link>
);
};
export default PortfolioCard;
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Loader2, Code2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import PortfolioCard from './PortfolioCard';
import type { Project } from '../utils';
import { getAllProjects } from '../utils';
// Neuer Typ, der die originale Project-Schnittstelle erweitert
type ProjectWithId = Project & { id: string };
const PortfolioGrid = () => {
const { t, i18n } = useTranslation();
const [projects, setProjects] = useState<ProjectWithId[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProjects = async () => {
try {
const projectsData = await getAllProjects();
// Ergänze das fehlende 'id'-Feld (verwende als Fallback den 'slug')
const projectsDataWithId = projectsData.map((project) => ({
...project,
id: project.slug,
})) as ProjectWithId[];
try {
const localeModule = await import(
`../../../i18n/locales/${i18n.language}/portfolio/index.ts`
);
// Mische die Projekte mit lokalisierten Inhalten, sofern verfügbar
const localizedProjects = projectsDataWithId.map((project) => {
const localizedProject = localeModule.portfolio.projects[project.slug];
if (localizedProject) {
return {
...project,
...localizedProject.meta,
content: localizedProject.content,
};
}
return project;
});
setProjects(localizedProjects);
} catch {
// Fallback: Verwende die Originaldaten, wenn keine Übersetzungen vorhanden sind
console.warn(
`Translations not found for ${i18n.language}, using default content`
);
setProjects(projectsDataWithId);
}
} catch (error) {
console.error('Error loading projects:', error);
} finally {
setLoading(false);
}
};
loadProjects();
}, [i18n.language]);
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh]">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center space-y-4"
>
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
<p className="text-zinc-400 text-sm">
{t('portfolio.ui.loading', 'Loading projects...')}
</p>
</motion.div>
</div>
);
}
if (projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center space-y-6 px-4 text-center"
>
<Code2 className="h-12 w-12 text-zinc-400" />
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">
{t('portfolio.ui.noProjects', 'No Projects Found')}
</h3>
<p className="text-zinc-400 text-sm max-w-md">
{t(
'portfolio.ui.noProjectsDescription',
'There are currently no projects to display. Check back later for updates.'
)}
</p>
</div>
</motion.div>
</div>
);
}
return (
<section className="py-12">
<div className="max-w-6xl mx-auto px-4">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-8"
>
{projects.map((project) => (
<motion.div
key={project.slug}
variants={itemVariants}
transition={{ duration: 0.5 }}
className="group"
>
<PortfolioCard project={project} />
</motion.div>
))}
</motion.div>
</div>
</section>
);
};
export default PortfolioGrid;
@@ -0,0 +1,63 @@
// components/ProjectContentTemplate.tsx
import React from 'react';
// Kopiere den Typ TranslatedContent, falls er nicht bereits exportiert wird.
// Alternativ importiere ihn aus dem entsprechenden Modul.
export interface TranslatedContent {
meta: {
title: string;
description: string;
date: string;
client: string;
duration: string;
technologies: string[];
};
content: {
intro: string;
challenge: {
title: string;
description: string;
points?: string[];
};
solution: {
title: string;
description: string;
content?: string;
points?: string[];
};
technical: {
title: string;
description: string;
points?: string[];
code?: string;
};
implementation: {
title: string;
description: string;
points?: string[];
};
results: {
title: string;
description: string;
points?: string[];
};
conclusion?: string;
};
}
interface ProjectContentTemplateProps {
content: TranslatedContent;
}
const ProjectContentTemplate: React.FC<ProjectContentTemplateProps> = ({ content }) => {
return (
<div>
{/* Hier renderst du den Inhalt des Projekts, z.B.: */}
<h1>{content.meta.title}</h1>
<p>{content.meta.description}</p>
{/* Und den restlichen Content ... */}
</div>
);
};
export default ProjectContentTemplate;
@@ -0,0 +1,121 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface ProjectLayoutProps {
children: React.ReactNode;
}
const ProjectLayout: React.FC<ProjectLayoutProps> = ({ children }) => {
const { t } = useTranslation();
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="min-h-screen relative overflow-x-hidden"
>
{/* Header */}
<motion.div
variants={itemVariants}
className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-zinc-900 to-transparent z-20"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div className="flex items-center gap-4 h-full">
<ArrowRight className="h-5 w-5 text-zinc-500" />
<h2 className="text-white text-sm tracking-wider">
{t('portfolio.ui.projectDetails', 'PROJECT DETAILS')}
</h2>
</div>
</div>
</motion.div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 relative z-10">
<article className="prose prose-invert max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-headings:tracking-tight
prose-h1:text-3xl sm:prose-h1:text-4xl lg:prose-h1:text-5xl
prose-h1:mb-6 sm:prose-h1:mb-8
prose-h2:text-2xl sm:prose-h2:text-3xl lg:prose-h2:text-4xl
prose-h2:mt-12 sm:prose-h2:mt-16
prose-h3:text-xl sm:prose-h3:text-2xl lg:prose-h3:text-3xl
prose-h3:mt-8 sm:prose-h3:mt-12
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-p:text-base sm:prose-p:text-lg
prose-a:text-white prose-a:no-underline
hover:prose-a:text-zinc-300
prose-a:transition-colors duration-300
prose-strong:text-white
prose-code:text-zinc-300 prose-code:bg-zinc-800/50
prose-code:rounded prose-code:px-1.5 prose-code:py-0.5
prose-pre:bg-zinc-800/50 prose-pre:border
prose-pre:border-zinc-800 prose-pre:rounded-lg
prose-pre:p-4 sm:prose-pre:p-6
prose-img:rounded-lg
prose-img:my-8
prose-ul:pl-6
prose-ol:pl-6
prose-li:marker:text-zinc-500
prose-li:text-zinc-300
prose-blockquote:border-l-2 prose-blockquote:border-zinc-700
prose-blockquote:pl-6 prose-blockquote:text-zinc-400"
>
<motion.div
variants={itemVariants}
className="space-y-8 sm:space-y-12"
>
{children}
</motion.div>
</article>
</div>
{/* Background Effects */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900/50 to-transparent" />
<div className="absolute top-0 right-0 -translate-y-12 translate-x-12 hidden sm:block">
<div className="grid grid-cols-3 gap-1 opacity-20">
{[...Array(9)].map((_, i) => (
<div
key={i}
className="w-1 h-1 rounded-full bg-zinc-600"
/>
))}
</div>
</div>
</div>
{/* Scroll Decoration */}
<div className="fixed inset-x-0 bottom-0 h-32 bg-gradient-to-t from-zinc-900 to-transparent pointer-events-none z-20" />
</motion.div>
);
};
export default ProjectLayout;
+62
View File
@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import SEO from '../../components/SEO';
import PortfolioGrid from './components/PortfolioGrid';
const PortfolioPage = () => {
const { t } = useTranslation();
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
return (
<>
<SEO
title={t('portfolio.seo.title')}
description={t('portfolio.seo.description')}
/>
<div className="relative min-h-screen">
{/* Main Content */}
<motion.section
initial="hidden"
animate="visible"
variants={sectionVariants}
transition={{ duration: 0.5 }}
className="py-24"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h1 className="text-3xl font-bold text-white mb-4">
{t('portfolio.sections.title')}
</h1>
<p className="text-zinc-400 max-w-2xl mx-auto">
{t('portfolio.sections.subtitle')}
</p>
</motion.div>
{/* Portfolio Grid */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<PortfolioGrid />
</motion.div>
</div>
</motion.section>
</div>
</>
);
};
export default PortfolioPage;
@@ -0,0 +1,154 @@
---
slug: "ai-data-reader"
title: "KI-basierte Produktdatenpflege aus PDF-Dateien"
description: "Entwicklung eines automatisierten Systems zur Extraktion und Strukturierung von Produktdaten aus PDF-Dokumenten"
excerpt: "Integration von Claude AI für die intelligente Verarbeitung von Produktinformationen und nahtlose JTL-ERP-Integration."
date: "2024-02-09"
category: "Data Processing"
coverImage: "/images/projects/ai-data-reader/cover.jpg"
client: "Amazon Seller"
duration: "2 Monate"
url: "https://www.damjan-savic.com"
repository: ""
documentation: ""
published: true
featured: true
technologies: ["Python", "FastAPI", "Claude AI", "PyPDF", "Pydantic", "JTL-Wawi"]
tags: ["Data Extraction", "ERP", "Process Automation", "PDF Processing"]
---
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Ein KI-gestütztes System zur automatisierten Extraktion von Produktdaten aus PDF-Dokumenten mit direkter Integration in JTL-Warenwirtschaftssysteme, entwickelt für maximale Effizienz in der Produktdatenpflege.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Systemarchitektur</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die Lösung basiert auf einer modularen Python-Architektur:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Robuste PDF-Textextraktion mit PyPDF</li>
<li>KI-gestützte Datenextraktion mit Claude AI</li>
<li>Validierung durch Pydantic Models</li>
<li>JTL-ERP Integration über CSV-Export</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Datenmodelle</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-python" style={{ color: "#f8f8f2" }}>
{`class ProductData(BaseModel):
artikel_nummer: str
name: str
hersteller: str
inhaltsstoffe: List[str]
allergene: List[str]
naehrwerte: NaehrwertData
nettogewicht: str
vegan: bool
vegetarisch: bool`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Extraktionsprozess</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Der automatisierte Workflow umfasst:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>PDF-Batch-Verarbeitung aus Input-Verzeichnis</li>
<li>Intelligente Texterkennung und -strukturierung</li>
<li>KI-basierte Datenzuordnung</li>
<li>Automatische Datenvalidierung</li>
<li>JTL-konformer CSV-Export</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Datenvalidierung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Mehrschichtige Validierungslogik:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Strukturvalidierung durch Pydantic</li>
<li>Geschäftsregelvalidierung</li>
<li>Format- und Typprüfungen</li>
<li>JTL-Kompatibilitätsprüfung</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>KI-Implementierung</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-python" style={{ color: "#f8f8f2" }}>
{`def _extract_with_claude(self, prompt: str) -> Dict[str, Any]:
"""Extrahiert strukturierte Daten mit Claude AI."""
response = self.client.messages.create(
model="claude-3-sonnet-20240229",
messages=[{
"role": "user",
"content": prompt
}]
)
return self._clean_json_string(response.content)`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Fehlerbehandlung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Robuste Fehlerbehandlung auf mehreren Ebenen:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>PDF-Lesefehler-Management</li>
<li>KI-Extraktionsfehlerbehandlung</li>
<li>Datenvalidierungsfehler-Reporting</li>
<li>Automatische Wiederholungsversuche</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>JTL-Integration</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Nahtlose Integration in JTL-Systeme:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Standardisierte CSV-Exportformate</li>
<li>Automatische Feldmappings</li>
<li>Datentyp-Konvertierung</li>
<li>Validierung der JTL-Kompatibilität</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Logging & Monitoring</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Umfassende Überwachung:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Detaillierte Verarbeitungslogs</li>
<li>Fehlerprotokolle</li>
<li>Performance-Metriken</li>
<li>Erfolgsraten-Tracking</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Ergebnisse</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die Implementierung führte zu signifikanten Verbesserungen:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>90% Zeitersparnis bei der Produktdatenpflege</li>
<li>99% Genauigkeit bei der Datenextraktion</li>
<li>Eliminierung manueller Dateneingaben</li>
<li>Standardisierte Produktdatenqualität</li>
</ul>
</div>
@@ -0,0 +1,132 @@
---
slug: "ai-music-production"
title: "Suno AI - KI-gestützte Musikproduktion"
description: "Kompletter Workflow für Musikproduktion mit Suno AI: Von Songtext zum fertigen Track"
excerpt: "Musikproduktion mit KI-Unterstützung von Suno AI. Vom handgeschriebenen Text zum fertigen Track in 2-3 Stunden."
date: "2024-12-01"
category: "AI & Automation"
coverImage: "/images/projects/ai-music-production/cover.jpg"
client: "Eigenprojekt"
duration: "Laufend"
url: "https://soundcloud.com/desetka"
repository: ""
documentation: ""
published: true
featured: true
technologies: ["Suno AI", "Soundcloud", "Pinterest", "Photoshop", "KI-Musikproduktion"]
tags: ["Suno AI", "Musikproduktion", "KI Musik", "Desetka", "Soundcloud", "Kreative KI"]
---
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Musikproduktion mit KI-Unterstützung: Kompletter Workflow zur Erstellung professioneller Songs mit Suno AI. Von der Idee über den Text zum fertigen Track auf Soundcloud - alles in einem strukturierten Prozess.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Die Herausforderung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Traditionelle Musikproduktion erfordert umfangreiche Ressourcen und Fähigkeiten:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.8" }}>
<li>Klassische Musikproduktion erfordert teure Software und Hardware</li>
<li>Instrumentalisten und Produzenten müssen koordiniert werden</li>
<li>Der Produktionsprozess dauert oft Wochen oder Monate</li>
<li>Hohe Einstiegshürden für Künstler ohne musikalische Ausbildung</li>
<li>Kreative Visionen lassen sich nur schwer schnell umsetzen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Der Lösungsansatz</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Mit Suno AI lässt sich der gesamte Musikproduktionsprozess auf wenige Stunden reduzieren. Der Workflow kombiniert menschliche Kreativität (Texte) mit KI-generierter Musik für authentische, professionelle Ergebnisse.
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.8" }}>
<li><strong>Texte werden von Hand geschrieben</strong> - volle kreative Kontrolle</li>
<li><strong>Suno AI generiert Instrumentals</strong> basierend auf Style Prompts</li>
<li><strong>Iteratives Remixen</strong> für den perfekten Sound</li>
<li><strong>Cover-Design</strong> mit Pinterest-Inspiration und Photoshop</li>
<li><strong>Direkter Upload</strong> auf Soundcloud unter dem Alias 'Desetka'</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Der Workflow</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Schritt für Schritt Prozess für KI-Musikproduktion:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.8" }}>
<li>Text schreiben mit klarem Reimschema (z.B. Endungen auf e, a, i)</li>
<li>Melodie gedanklich beim Schreiben entwickeln</li>
<li>Instrumental in Suno AI mit Style Prompts generieren</li>
<li>Weirdness (35%) und Audio Influence (75%) konfigurieren</li>
<li>Verschiedene Styles mischen (z.B. Synthwave → Luxury Rap)</li>
<li>Favoriten liken für KI-Lerneffekt</li>
<li>Cover auf Pinterest finden und in Photoshop bearbeiten (1950x1950px)</li>
<li>WAV Export und Upload auf Soundcloud</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Suno AI Konfiguration</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`// Suno AI Style Konfiguration
const sunoConfig = {
name: "205 Matrica Instrumental",
settings: {
weirdness: "35%",
styleInfluence: "75%",
audioInfluence: "25-35%" // Niedriger für Remastered
},
styles: [
"Synthwave",
"Melodic RIP",
"Luxury Rap"
],
tips: [
"Gelikte Songs beeinflussen zukünftige Generierungen",
"Bei Artefakten: Audio Influence reduzieren",
"Styles kombinieren für einzigartigen Sound"
]
};`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Kreativer Prozess</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die Kunst des Songschreibens mit KI-Unterstützung:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.8" }}>
<li>Texte auf Serbisch schreiben (melodischer Klang)</li>
<li>Klares Reimschema für Hook und Strophen</li>
<li>Melodie gedanklich vor Produktion entwickeln</li>
<li>Keine Instrumentals für Anfangsideen nötig</li>
<li>Übersetzung ins Englische später möglich</li>
<li>2-3 Stunden für kompletten Song</li>
<li>An guten Tagen: 3 Songs pro Stunde möglich</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Ergebnisse</h2>
<div style={{ marginBottom: "30px" }}>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.8" }}>
<li><strong>Produktionszeit</strong> von Wochen auf Stunden reduziert</li>
<li><strong>Volle kreative Kontrolle</strong> über Texte und Richtung</li>
<li><strong>Professionelle Instrumentals</strong> ohne Studio</li>
<li><strong>Iteratives Arbeiten</strong> bis zum perfekten Sound</li>
<li><strong>Direkter Weg</strong> vom Konzept zur Veröffentlichung</li>
<li><strong>Künstler-Alias 'Desetka'</strong> auf Soundcloud etabliert</li>
</ul>
</div>
<div style={{ margin: "40px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
KI-Musikproduktion mit Suno AI revolutioniert den kreativen Prozess. Die Kombination aus handgeschriebenen Texten und KI-generierten Instrumentals ermöglicht schnelle und professionelle Umsetzung musikalischer Visionen. Der Mensch bleibt kreativ im Mittelpunkt - KI ist das Werkzeug zur Realisierung.
</p>
</div>
@@ -0,0 +1,146 @@
---
slug: "automated-ad-creatives"
title: "Automatisierte Ad Creatives: KI-gestützte Werbemittel-Erstellung"
description: "Entwicklung eines Workflows zur automatisierten Erstellung von Facebook Ad Creatives mit Claude Opus 4.5 - ohne Designsoftware, komplett im Chat"
excerpt: "Von der Designvorlage zum fertigen Werbemittel: Wie KI die Ad-Creative-Erstellung revolutioniert."
date: "2024-12-12"
category: "KI & Automatisierung"
coverImage: "/images/projects/automated-ad-creatives/cover.jpg"
client: "Eigenprojekt"
duration: "1 Tag"
url: ""
repository: ""
documentation: ""
published: true
featured: true
technologies: ["Claude Opus 4.5", "HTML/CSS", "PNG Export", "Prompt Engineering"]
tags: ["KI", "Automatisierung", "Facebook Ads", "Marketing", "No-Code"]
videoUrl: "https://www.tella.tv/video/automatisierte-ad-creatives-1kyw"
---
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', marginBottom: '2rem', borderRadius: '12px' }}>
<iframe
src="https://www.tella.tv/video/automatisierte-ad-creatives-1kyw/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0"
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allowFullScreen
loading="lazy"
/>
</div>
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Programme wie Illustrator, InDesign, Figma oder Canva können kompliziert sein. Dieses Projekt zeigt, wie sich Facebook Ad Creatives vollständig im KI-Chat erstellen lassen - ohne Designsoftware, ohne Programmierkenntnisse.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Die Herausforderung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die Erstellung von Werbemitteln für Facebook-Kampagnen erfordert traditionell:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Kenntnisse in Designsoftware (Illustrator, Figma, Canva)</li>
<li>Zeit für Einarbeitung und Umsetzung</li>
<li>Verständnis für Formatvorgaben und Best Practices</li>
<li>Iterationen zwischen Design und Marketing</li>
<li>Budget für Designer oder Design-Tools</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Der Lösungsansatz</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Ein KI-gestützter Workflow, der den gesamten Prozess im Chat-Fenster abbildet:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Designvorlage als visuelle Referenz für die KI</li>
<li>HTML-Generierung durch natürlichsprachliche Prompts</li>
<li>Automatische Konvertierung zu PNG-Bilddateien</li>
<li>Integration zusätzlicher Bilder per Drag & Drop</li>
<li>Iterationen durch einfache Chat-Anweisungen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Technische Umsetzung</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Der Workflow im Detail</h3>
<h4 style={{ marginTop: "15px", marginBottom: "10px" }}>Schritt 1: Designgrundlage</h4>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Auf Envato Elements nach "Facebook Ads Kampagne" suchen und ein passendes Template als Designreferenz herunterladen. Dieses Bild definiert Stil, Farbgebung und Layout.
</p>
<h4 style={{ marginTop: "15px", marginBottom: "10px" }}>Schritt 2: Erster Prompt</h4>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Das Referenzbild zusammen mit einem beschreibenden Prompt an Claude Opus 4.5 senden. Die KI analysiert das Bild und generiert eine HTML-Datei, die das Design nachbildet.
</p>
<h4 style={{ marginTop: "15px", marginBottom: "10px" }}>Schritt 3: HTML zu PNG</h4>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Im zweiten Prompt die KI bitten, die HTML-Datei als PNG zu exportieren. Das Ergebnis ist eine fertige Bilddatei für Facebook Ads.
</p>
<h4 style={{ marginTop: "15px", marginBottom: "10px" }}>Schritt 4: Bilder hinzufügen</h4>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Zusätzliche Bilder (z.B. von Unsplash) per Drag & Drop in den Chat ziehen. Die KI integriert sie in das bestehende Creative.
</p>
</div>
<h2 style={{ marginBottom: "20px" }}>Verwendete Tools</h2>
<div style={{ marginBottom: "30px" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "10px" }}>Tool</th>
<th style={{ textAlign: "left", padding: "10px" }}>Zweck</th>
</tr>
</thead>
<tbody>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Claude Opus 4.5</strong></td>
<td style={{ padding: "10px" }}>KI für HTML-Generierung und Bildexport</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Envato Elements</strong></td>
<td style={{ padding: "10px" }}>Designvorlagen als Referenz</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Unsplash</strong></td>
<td style={{ padding: "10px" }}>Kostenlose Bilder für die Creatives</td>
</tr>
</tbody>
</table>
</div>
<h2 style={{ marginBottom: "20px" }}>Vorteile des Ansatzes</h2>
<div style={{ marginBottom: "30px" }}>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Keine Design-Skills nötig:</strong> Die KI übernimmt die technische Umsetzung</li>
<li><strong>Schnelle Iterationen:</strong> Änderungen in Sekunden durch Chat-Anweisungen</li>
<li><strong>Skalierbar:</strong> Beliebig viele Varianten für A/B-Tests</li>
<li><strong>Kosteneffizient:</strong> Keine teuren Design-Tools erforderlich</li>
<li><strong>Flexibel:</strong> Funktioniert auch mit Gemini oder ChatGPT</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Einsatzszenarien</h2>
<div style={{ marginBottom: "30px" }}>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Performance-Marketing:</strong> Schnelle Creative-Tests ohne Wartezeit</li>
<li><strong>E-Commerce:</strong> Produktwerbung in großem Umfang</li>
<li><strong>Startups:</strong> Professionelle Ads ohne Design-Team</li>
<li><strong>Agenturen:</strong> Effiziente Kundenarbeit bei Ad-Erstellung</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Der Workflow demonstriert, wie KI die Erstellung von Ad Creatives vereinfacht. Das Ergebnis ist vielleicht noch nicht auf dem Niveau eines professionellen Grafikdesigners - aber für schnelle Tests, erste Entwürfe oder Teams ohne Designressourcen ist dieser Ansatz eine echte Alternative.
</p>
<p style={{ fontSize: "1rem", lineHeight: "1.6", marginTop: "15px" }}>
Die Technologie entwickelt sich rasant weiter. Was heute noch "nicht perfekt integriert" ist, wird in wenigen Monaten deutlich besser funktionieren.
</p>
</div>
@@ -0,0 +1,155 @@
---
slug: "cursor-ide"
title: "Cursor IDE: KI-gestützte Softwareentwicklung"
description: "Einführung in Cursor - die revolutionäre KI-gestützte Entwicklungsumgebung, die Softwareprojekte durch natürliche Sprache ermöglicht"
excerpt: "Entdecke, wie Cursor IDE als persönlicher KI-Programmierer fungiert und komplette Projekte aus natürlicher Sprache erstellt."
date: "2024-12-15"
category: "KI & Entwicklung"
coverImage: "/images/projects/cursor-ide/cover.jpg"
client: "Tutorial"
duration: "Einführung"
url: "https://cursor.com"
repository: ""
documentation: ""
published: true
featured: true
technologies: ["Cursor IDE", "KI", "Next.js", "React", "TypeScript", "Node.js"]
tags: ["KI", "IDE", "Entwicklung", "Tutorial", "Code-Generierung", "Automatisierung"]
videoUrl: "https://www.tella.tv/video/cursor-ide-1-1kjg"
---
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', marginBottom: '2rem', borderRadius: '12px' }}>
<iframe
src="https://www.tella.tv/video/cursor-ide-1-1kjg/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0"
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allowFullScreen
loading="lazy"
/>
</div>
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Cursor ist eine KI-gestützte Entwicklungsumgebung, die wie ein persönlicher Programmierer funktioniert. Sie versteht natürliches Deutsch, generiert Code eigenständig, findet und behebt Fehler - und baut komplette Softwareprojekte auf Zuruf.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Was ist Cursor?</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Stell dir vor, du hättest einen persönlichen Programmierer, der neben dir sitzt und alle deine Softwareideen in natürlichem gesprochenen Deutsch verstehen und aufnehmen kann. Dieser würde diese gesamten Softwareprojekte auch eigenständig aufbauen können. Genau das ist Cursor.
</p>
<p style={{ fontSize: "1rem", lineHeight: "1.6", marginTop: "15px" }}>
Cursor ist ein Texteditor mit integrierter KI:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Code für dich schreiben</li>
<li>Fragen beantworten</li>
<li>Komplette Projekte aufbauen</li>
<li>Fehler finden und beheben</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Früher vs. Heute</h2>
<div style={{ marginBottom: "30px" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "10px" }}>Früher</th>
<th style={{ textAlign: "left", padding: "10px" }}>Mit Cursor</th>
</tr>
</thead>
<tbody>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}>Programmiersprachen lernen musste</td>
<td style={{ padding: "10px" }}>Einfach beschreiben, was du haben möchtest</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}>Fehlersuche dauerte Stunden</td>
<td style={{ padding: "10px" }}>KI findet und behebt Fehler automatisch</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}>Jede Zeile selbst tippen</td>
<td style={{ padding: "10px" }}>KI generiert den Code</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}>Google und Stackoverflow als Begleiter</td>
<td style={{ padding: "10px" }}>KI erklärt das gesamte Projekt im Editor</td>
</tr>
</tbody>
</table>
</div>
<h2 style={{ marginBottom: "20px" }}>Das Interface</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Nach dem Download über <a href="https://cursor.com" target="_blank" rel="noopener noreferrer">cursor.com</a> und Installation besteht das Interface aus:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Menüleiste (oben):</strong> Navigation durch die IDE</li>
<li><strong>File Explorer (links):</strong> Projektstruktur mit Verzeichnissen und Dateien</li>
<li><strong>Editor (Mitte):</strong> Ansicht und Bearbeitung von Dateien</li>
<li><strong>Terminal (unten):</strong> Einblendbar über View &gt; Terminal</li>
<li><strong>KI Chat (rechts):</strong> Steuerung der Entwicklung per KI</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Praxisbeispiel: Website erstellen</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Ein einfacher Prompt wie:
</p>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code style={{ color: "#f8f8f2" }}>
{`Ich möchte eine Website aufbauen mit Next, React
und TypeScript mit einer modernen Darstellung
von Hello World.`}
</code>
</pre>
<p style={{ fontSize: "1rem", lineHeight: "1.6", marginTop: "15px" }}>
Die KI fängt sofort mit der Entwicklung an:
</p>
<ol style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Projektzustand wird geprüft</li>
<li>Das gesamte Projekt wird aufgebaut</li>
<li>Nach wenigen Sekunden steht die Website</li>
</ol>
</div>
<h2 style={{ marginBottom: "20px" }}>Website starten</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Nach der Codegenerierung:
</p>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code style={{ color: "#f8f8f2" }}>
{`# Abhängigkeiten installieren
npm install
# Entwicklungsserver starten
npm run dev`}
</code>
</pre>
<p style={{ fontSize: "1rem", lineHeight: "1.6", marginTop: "15px" }}>
Besonders praktisch: Ein integrierter Browser in der Editor-Ansicht ermöglicht die direkte Vorschau der Website während der Entwicklung.
</p>
</div>
<h2 style={{ marginBottom: "20px" }}>Erste Schritte</h2>
<div style={{ marginBottom: "30px" }}>
<ol style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Cursor von <a href="https://cursor.com" target="_blank" rel="noopener noreferrer">cursor.com</a> herunterladen</li>
<li>Installation für das jeweilige Betriebssystem durchführen</li>
<li>Projektordner anlegen und in Cursor öffnen</li>
<li>Im KI-Chat beschreiben, was gebaut werden soll</li>
<li>Die KI arbeiten lassen und bei Bedarf anpassen</li>
</ol>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Cursor revolutioniert die Softwareentwicklung, indem es die Barriere zwischen Idee und Umsetzung dramatisch senkt. Statt Programmiersprachen zu lernen, beschreibst du einfach in natürlicher Sprache, was du haben möchtest - und die KI erledigt den Rest.
</p>
</div>
@@ -0,0 +1,229 @@
---
slug: "kamenpro"
title: "KamenPro: Digitale Transformation für dekorative Steinverkleidungen"
description: "Entwicklung einer modernen Multi-Location PWA für einen Hersteller dekorativer Steinverkleidungen aus Bijeljina mit React 18.3 und Supabase"
excerpt: "Von Stein zu Pixel: Wie ein traditioneller Handwerksbetrieb durch moderne Webtechnologie 300% mehr Anfragen generiert."
date: "2024-11-15"
category: "Web Development"
coverImage: "/images/projects/kamenpro/cover.jpg"
client: "KamenPro - Željko"
duration: "3 Monate"
url: "https://kamenpro.net"
repository: ""
documentation: ""
published: true
featured: true
technologies: ["React 18.3", "TypeScript", "Vite", "Supabase", "Framer Motion", "TailwindCSS", "PWA", "Schema.org"]
tags: ["Multi-Location SEO", "E-Commerce", "PWA", "Local Business", "Performance"]
---
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
KamenPro, ein etablierter Hersteller dekorativer Steinverkleidungen aus Bijeljina, stand vor der Herausforderung, sein traditionelles Handwerk in die digitale Welt zu übertragen. Diese Case Study zeigt, wie moderne Webtechnologie einem lokalen Handwerksbetrieb zu überregionalem Erfolg verhalf.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Die Herausforderung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Als Hersteller hochwertiger Steinverkleidungen aus Weißzement fehlte KamenPro die digitale Präsenz:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Keine digitale Präsenz trotz hoher Online-Nachfrage</li>
<li>Kunden suchten online nach 'dekorativni kamen' ohne KamenPro zu finden</li>
<li>Fehlende Produktpräsentation für 3 verschiedene Steinstrukturen</li>
<li>Keine lokale SEO-Präsenz in Bijeljina, Brčko und Tuzla</li>
<li>Verlust potenzieller Kunden an digital präsente Konkurrenz</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Der Lösungsansatz</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Eine moderne PWA mit Multi-Location SEO-Strategie für maximale lokale Sichtbarkeit:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Multi-Location SEO für Bijeljina, Brčko und Tuzla</li>
<li>Produktkatalog mit HD-Bildern der Steinstrukturen</li>
<li>PWA für Offline-Verfügbarkeit auf Baustellen</li>
<li>Supabase-Backend für Produktverwaltung und Anfragen</li>
<li>Schema.org LocalBusiness für optimale Google-Präsenz</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Technische Implementierung</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Multi-Location SEO mit Schema.org</h3>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`// Standort-spezifische Landing Pages
const LocationPage: React.FC<{city: string}> = ({ city }) => {
const structuredData = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": \`KamenPro \${city}\`,
"description": \`Dekorativni kamen i fasadne obloge u \${city}\`,
"telephone": "+387 65 678 634",
"address": {
"@type": "PostalAddress",
"addressLocality": city,
"addressCountry": "BA"
}
};
return (
<>
<Helmet>
<title>Dekorativni kamen {city} | KamenPro</title>
<script type="application/ld+json">
{JSON.stringify(structuredData)}
</script>
</Helmet>
<LocationHero city={city} />
<ProductShowcase />
</>
);
};`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Produktverwaltung mit Supabase</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`interface StoneProduct {
id: string;
name: string;
type: 'decorative_stone' | 'rustic_brick';
dimensions: {
length: 44, // cm
width: 8.5, // cm
thickness: 15 // mm
};
price_per_m2: number; // 33-40 BAM
weight_per_m2: 32; // kg
textures: string[]; // 3 verschiedene
available_colors: string[];
}
// Produkt-Service für Katalog
class ProductService {
async getProducts(filters?: {
type?: string;
priceRange?: [number, number];
texture?: string;
}) {
let query = supabase
.from('products')
.select('*, product_images (*)')
.eq('is_active', true);
if (filters?.type) {
query = query.eq('type', filters.type);
}
const { data, error } = await query;
return this.transformProducts(data);
}
}`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>PWA für Offline-Verfügbarkeit</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-javascript" style={{ color: "#f8f8f2" }}>
{`// Service Worker für Offline-Katalog
self.addEventListener('install', event => {
event.waitUntil(
caches.open('kamenpro-v1').then(cache => {
return cache.addAll([
'/',
'/offline.html',
'/katalog',
// Kritische Produktbilder
'/images/products/dekorativni-kamen-preview.webp',
'/images/products/rustik-cigla-preview.webp'
]);
})
);
});
// Network-first für Produktdaten
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/products')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Cache aktualisieren
const responseClone = response.clone();
caches.open('kamenpro-api').then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match(event.request))
);
}
});`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Lokale SEO-Erfolge</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Durch gezielte Multi-Location-Optimierung erreichten wir Top-Rankings:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Rank #1 für 'dekorativni kamen bijeljina'</li>
<li>Rank #2 für 'fasadne obloge brčko'</li>
<li>Rank #1 für 'rustik cigla tuzla'</li>
<li>Google My Business Integration für alle Standorte</li>
<li>Lokale Backlinks von Bauunternehmen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Messbare Geschäftsergebnisse</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die digitale Transformation brachte beeindruckende Ergebnisse:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>300% mehr Anfragen in den ersten 3 Monaten</li>
<li>Von 0 auf Platz 1-3 bei lokalen Suchanfragen</li>
<li>45 Anfragen/Monat statt vorher ~12</li>
<li>25-30 Projekte/Quartal statt 8-10</li>
<li>ROI von 275% in 6 Monaten</li>
<li>Expansion in neue Märkte durch Online-Präsenz</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Produktspezifikationen</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
KamenPro's Produktpalette umfasst:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Dekorativer Stein:</strong> 44cm x 8.5cm, 15-20mm dick</li>
<li><strong>Rustikale Ziegel:</strong> 5mm dick, wetterbeständig</li>
<li><strong>Gewicht:</strong> 30-35 kg/m²</li>
<li><strong>Material:</strong> Weißzement-Basis mit Additiven</li>
<li><strong>Preise:</strong> 33-40 BAM (Stein), 25-30 BAM (Ziegel)</li>
<li><strong>Varianten:</strong> 3 verschiedene Texturen, Farben nach Wunsch</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die digitale Transformation von KamenPro zeigt eindrucksvoll, wie ein traditioneller Handwerksbetrieb durch moderne Webtechnologie neue Märkte erschließen kann.
Die Kombination aus Multi-Location SEO, optimierter Produktpräsentation und technischer Exzellenz führte zu einer Verdreifachung der Kundenanfragen.
Besonders bemerkenswert: Ein lokaler Steinverkleidungs-Hersteller aus Bijeljina erreicht nun Kunden in der gesamten Region -
ein Beweis dafür, dass durchdachte Digitalisierung auch im traditionellen Handwerk messbare Erfolge bringt.
</p>
</div>
@@ -0,0 +1,320 @@
---
slug: "power-platform-governance"
title: "Power Platform Governance & Automation Suite"
description: "Enterprise-Governance-Plattform für Microsoft Power Platform und SharePoint Online"
excerpt: "Zentrale Verwaltung, Überwachung und Automatisierung der gesamten M365-Umgebung - von Tenant-Provisionierung bis Compliance-Monitoring."
date: "2024-09-15"
category: "Enterprise Software"
coverImage: "/images/projects/power-platform-governance/cover.jpg"
client: "Enterprise CoE Teams"
duration: "6 Monate"
url: "https://governance-demo.azurewebsites.net"
repository: ""
documentation: "/docs/power-platform-governance"
published: true
featured: true
technologies: ["React", "TypeScript", "Node.js", "Microsoft Graph", "PowerShell", "Azure Functions", "GraphQL", "Fluent UI"]
tags: ["Microsoft 365", "Power Platform", "SharePoint", "Governance", "Automation", "Enterprise"]
---
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Eine umfassende Enterprise-Governance-Plattform, die IT-Administratoren und Power Platform CoE Teams ermöglicht, ihre gesamte M365-Umgebung zentral zu verwalten. Von automatisierter Provisionierung über Compliance-Monitoring bis zur intelligenten Ressourcenverwaltung - alles in einer einzigen, leistungsstarken Lösung.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Die Herausforderung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Unkontrolliertes Wachstum von Power Apps, Flows und SharePoint-Sites führte zu kritischen Problemen:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Shadow IT durch ungovernte Citizen Development</li>
<li>Compliance-Risiken und Datenschutzverletzungen</li>
<li>Explodierende Lizenzkosten durch ungenutzte Ressourcen</li>
<li>Fehlende Übersicht über die Power Platform Landschaft</li>
<li>Manuelle, fehleranfällige Provisionierungsprozesse</li>
<li>Inkonsistente Governance-Policies über Teams hinweg</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Die Lösung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Eine zentrale Governance-Plattform mit folgenden Kernbausteinen:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Automated Provisioning Hub für Sites, Teams und Environments</li>
<li>Real-Time Monitoring Dashboard mit Live Activity Streams</li>
<li>Power Platform Governance Center mit DLP Policy Enforcement</li>
<li>Compliance & Security Suite mit Permission Analyzer</li>
<li>PowerShell Automation Framework für Custom Operations</li>
<li>Intelligent Resource Optimization mit ML-based Predictions</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Technische Architektur</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code style={{ color: "#f8f8f2" }}>
{`┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Web Portal │────▶│ GraphQL API │────▶│ Auth Service │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌────────────┴────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Provisioning │ │ Monitoring │
│ Service │ │ Service │
└─────────────────┘ └─────────────────┘
│ │
└────────────┬────────────┘
┌─────────────────┐
│ Message Queue │
│ (Service Bus) │
└─────────────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PowerShell │ │ Graph API │ │ Analytics │
│ Executor │ │ Gateway │ │ Engine │
└───────────────┘ └───────────────┘ └───────────────┘`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Automated Provisioning Hub</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Site Provisioning Engine</h3>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`class SiteProvisioningEngine {
async provisionSite(template: SiteTemplate, params: ProvisioningParams) {
// Validate naming conventions
const siteName = this.validateNaming(params.name);
// Create site from template
const site = await this.graph.sites.create({
displayName: siteName,
template: template.id,
owner: params.owner,
classification: params.classification
});
// Apply custom configurations
await this.applyCustomizations(site, template.customizations);
// Setup permissions
await this.configurePermissions(site, params.permissions);
// Execute post-provisioning workflows
await this.runPostProvisioningWorkflows(site, template.workflows);
return site;
}
}`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Real-Time Monitoring</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`const ActivityMonitor: React.FC = () => {
const { data: activities } = useSubscription(ACTIVITY_SUBSCRIPTION);
return (
<Stack tokens={{ childrenGap: 10 }}>
<Text variant="xLarge">Platform Activity</Text>
<DetailsList
items={activities}
columns={[
{ key: 'timestamp', name: 'Time', minWidth: 100 },
{ key: 'user', name: 'User', minWidth: 150 },
{ key: 'action', name: 'Action', minWidth: 200 },
{ key: 'resource', name: 'Resource', minWidth: 200 },
{ key: 'status', name: 'Status', minWidth: 80 }
]}
onRenderItemColumn={renderActivityColumn}
/>
</Stack>
);
};`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>PowerShell Automation Framework</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-powershell" style={{ color: "#f8f8f2" }}>
{`function New-GovernanceReport {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]$TenantId,
[ValidateSet('Sites','Apps','Flows','All')]
[string]$Scope = 'All',
[DateTime]$StartDate = (Get-Date).AddDays(-30)
)
# Complex governance analysis logic
$results = @{
ComplianceScore = Get-ComplianceScore -TenantId $TenantId
UnusedResources = Find-UnusedResources -Scope $Scope
SecurityRisks = Analyze-SecurityPosture -StartDate $StartDate
CostOptimization = Calculate-OptimizationPotential
}
Export-GovernanceReport -Results $results
}`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Compliance & Security Suite</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Permission Analyzer</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Cross-tenant Permission Reports mit visueller Darstellung</li>
<li>Overprivileged Users Detection durch ML-Algorithmen</li>
<li>External Sharing Audit mit Risikobewertung</li>
<li>Inheritance Breaking Analysis für SharePoint Sites</li>
<li>Automated Access Reviews mit Approval Workflows</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Data Loss Prevention</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Custom DLP Policy Builder mit Drag-and-Drop Interface</li>
<li>Sensitive Data Discovery durch Pattern Matching</li>
<li>Real-time Policy Violation Alerts</li>
<li>Automated Remediation Actions</li>
<li>Compliance Dashboards mit Trend Analysis</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Policy as Code</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-yaml" style={{ color: "#f8f8f2" }}>
{`# Governance Policy Definition
apiVersion: governance/v1
kind: SitePolicy
metadata:
name: external-sharing-policy
spec:
rules:
- effect: Deny
resource: "sites/*"
action: "share.external"
condition:
classification: "Confidential"
- effect: Audit
resource: "sites/*"
action: "permissions.break"
enforcement:
mode: "preventive"
notifications:
- channel: "teams"
webhook: "${TEAMS_WEBHOOK_URL}"`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Intelligent Resource Optimization</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-typescript" style={{ color: "#f8f8f2" }}>
{`class ResourceOptimizer {
async analyzeUsage() {
const resources = await this.getAllResources();
for (const resource of resources) {
const usage = await this.mlEngine.predictUsage(resource);
if (usage.probability < 0.1) {
await this.scheduleForCleanup(resource, {
reason: 'Low usage probability',
confidence: usage.confidence,
suggestedAction: 'archive',
scheduledDate: this.calculateCleanupDate(usage)
});
}
}
return this.generateOptimizationReport();
}
}`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Projekt-Metriken & Ergebnisse</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Quantitative Ergebnisse</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>95% schneller:</strong> Provisioning Zeit von 5 Min auf 15 Sek reduziert</li>
<li><strong>94% Compliance:</strong> Rate von 67% gesteigert</li>
<li><strong>40% Kostenreduktion:</strong> Durch Bereinigung ungenutzter Ressourcen</li>
<li><strong>3x Produktivität:</strong> Admin-Effizienz verdreifacht</li>
<li><strong>80% schneller:</strong> Incident Response Time verbessert</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Technische Achievements</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>35.000+ Lines of Code</li>
<li>45+ React Components mit Fluent UI</li>
<li>120+ Custom PowerShell Cmdlets</li>
<li>80+ GraphQL Endpoints</li>
<li>92% Test Coverage</li>
<li>25+ aktive Enterprise-Tenants</li>
<li>50.000+ verwaltete Ressourcen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>UI/UX Features</h2>
<div style={{ marginBottom: "30px" }}>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Customizable Dashboards mit Drag-and-Drop Widgets</li>
<li>Dark Mode mit vollem Theme Support</li>
<li>Command Palette für Quick Actions (Ctrl+K)</li>
<li>Bulk Operations mit Multi-Select</li>
<li>Export nach Excel, PDF und Power BI</li>
<li>Mobile Responsive für Tablet-Nutzung</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Zukunftsperspektiven</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Roadmap 2025</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>AI-Powered Insights mit Anomalie-Erkennung</li>
<li>Copilot Integration für Natural Language Governance</li>
<li>Microsoft Fabric Analytics Integration</li>
<li>Container Apps für serverlose PowerShell-Execution</li>
<li>Zero Trust Security Model Implementation</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Vision 2026</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Autonomous Governance mit selbstheilender Umgebung</li>
<li>Cross-Cloud Support für AWS/GCP</li>
<li>Blockchain-basierte Audit Logs</li>
<li>Quantum-Safe Encryption</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Diese Plattform setzt neue Standards für Power Platform Governance und demonstriert, wie moderne Cloud-Technologien mit Microsoft 365 kombiniert werden können, um Enterprise-Grade Automatisierung und Compliance zu erreichen.
Die Lösung ermöglicht es Unternehmen, die Balance zwischen Innovation und Kontrolle zu finden, während sie gleichzeitig Kosten senkt und die Sicherheit erhöht.
</p>
</div>
@@ -0,0 +1,270 @@
---
slug: "smart-warehouse"
title: "Intelligente Lagerverwaltung mit Computer Vision"
description: "KI-gestütztes System zur Echtzeit-Bestandsführung und Optimierung von Lagerprozessen"
excerpt: "Entwicklung eines autonomen Lagerverwaltungssystems mit Computer Vision, IoT-Sensorik und Machine Learning für präzise Bestandsführung."
date: "2024-06-15"
category: "AI & Automation"
coverImage: "/images/projects/smart-warehouse/cover.jpg"
client: "LogiTech Solutions GmbH"
duration: "4 Monate"
url: "https://warehouse-demo.example.com"
repository: ""
documentation: "/case-studies/smart-warehouse"
published: true
featured: true
technologies: ["YOLOv8", "Python", "FastAPI", "React", "PostgreSQL", "Docker", "Kubernetes", "IoT", "MQTT"]
tags: ["Computer Vision", "Machine Learning", "IoT", "Edge Computing", "Automation"]
---
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Ein mittelständischer Logistikdienstleister transformierte seine manuelle Lagerverwaltung durch ein vollautomatisiertes System, das Computer Vision, IoT-Sensorik und Machine Learning kombiniert. Diese Case Study zeigt, wie KI-gestützte Bilderkennung zu 98,7% Genauigkeit bei der Bestandserfassung führte.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Die Herausforderung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die manuelle Bestandsführung führte zu erheblichen operativen Problemen:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Diskrepanzen zwischen tatsächlichem und gebuchtem Bestand von bis zu 15%</li>
<li>Zeitintensive manuelle Inventuren (3-4 Tage pro Quartal)</li>
<li>Fehlende Echtzeit-Transparenz über Lagerbestände</li>
<li>Ineffiziente Lagerplatznutzung durch fehlende Optimierung</li>
<li>Hohe Personalkosten durch manuelle Prozesse</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Die Lösung</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Entwicklung eines vollautomatisierten Lagerverwaltungssystems mit folgenden Kernkomponenten:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>KI-gestützte Objekterkennung mittels hochauflösender Kameras</li>
<li>IoT-Gewichtssensoren zur Validierung</li>
<li>Predictive Analytics für Bestandsoptimierung</li>
<li>Echtzeit-Dashboard mit mobilem Zugriff</li>
<li>Automatische Nachbestellungstrigger</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Systemarchitektur</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code style={{ color: "#f8f8f2" }}>
{`┌─────────────┐ ┌──────────────┐ ┌────────────┐
│ Kameras │────▶│ Edge Server │────▶│ ML Cloud │
└─────────────┘ └──────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌────────────┐
│ IoT Sensoren│────▶│ MQTT Broker │────▶│ API GW │
└─────────────┘ └──────────────┘ └────────────┘
│ │
▼ ▼
┌──────────────┐ ┌────────────┐
│ PostgreSQL │◀────│ Dashboard │
└──────────────┘ └────────────┘`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Computer Vision Pipeline</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-python" style={{ color: "#f8f8f2" }}>
{`# Objekterkennung mit YOLOv8
model = YOLO('yolov8x-custom.pt')
results = model.predict(
source=camera_feed,
conf=0.85,
save=False,
stream=True
)
# Bestandszählung und Klassifizierung
inventory_count = process_detections(results)
validate_with_sensors(inventory_count, weight_data)
# Echtzeit-Update der Datenbank
async def update_inventory(items: List[DetectedItem]):
async with db.transaction():
for item in items:
await db.execute(
"""UPDATE inventory
SET quantity = $1,
last_seen = $2,
confidence = $3
WHERE sku = $4""",
item.quantity,
datetime.now(),
item.confidence,
item.sku
)`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>IoT-Integration</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-python" style={{ color: "#f8f8f2" }}>
{`class IoTSensorManager:
def __init__(self):
self.mqtt_client = mqtt.Client()
self.sensors = {}
async def process_weight_data(self, sensor_id: str, weight: float):
"""Validiert CV-Ergebnisse mit Gewichtsdaten"""
location = self.sensors[sensor_id].location
expected_items = await self.get_cv_prediction(location)
# Gewichtsvalidierung
expected_weight = sum(item.weight for item in expected_items)
variance = abs(weight - expected_weight) / expected_weight
if variance > 0.1: # 10% Toleranz
await self.trigger_recount(location)
return {
'sensor_id': sensor_id,
'measured_weight': weight,
'expected_weight': expected_weight,
'variance': variance,
'status': 'valid' if variance <= 0.1 else 'recount_needed'
}`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Implementierungsphasen</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Phase 1: Proof of Concept (4 Wochen)</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Entwicklung eines Prototyps für einen Lagerbereich</li>
<li>Training des ML-Modells mit 10.000+ annotierten Bildern</li>
<li>Integration von 5 Testkameras und 20 IoT-Sensoren</li>
<li>Validierung der Erkennungsgenauigkeit</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Phase 2: Skalierung (8 Wochen)</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Rollout auf gesamtes Lager (5.000m²)</li>
<li>Installation von 45 Kameras und 200+ Sensoren</li>
<li>Entwicklung des Echtzeit-Dashboards</li>
<li>Integration in bestehendes ERP-System</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Phase 3: Optimierung (4 Wochen)</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Fine-Tuning der ML-Modelle</li>
<li>Implementierung von Predictive Analytics</li>
<li>Mobile App Entwicklung</li>
<li>Schulung der Mitarbeiter</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Herausforderungen & Lösungen</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Variierende Lichtverhältnisse</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
<strong>Problem:</strong> Schatten und Reflexionen beeinträchtigten die Erkennung<br/>
<strong>Lösung:</strong> HDR-Kameras + adaptive Bildvorverarbeitung + Augmentierung der Trainingsdaten
</p>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Echtzeitverarbeitung großer Datenmengen</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
<strong>Problem:</strong> 45 Kameras generierten 2TB Daten/Tag<br/>
<strong>Lösung:</strong> Edge Computing für Vorverarbeitung + Stream Processing mit Apache Kafka
</p>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Integration in Legacy-Systeme</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
<strong>Problem:</strong> 20 Jahre altes ERP ohne moderne APIs<br/>
<strong>Lösung:</strong> Entwicklung eines Adapter-Layers mit bidirektionaler Synchronisation
</p>
</div>
<h2 style={{ marginBottom: "20px" }}>Performance Metriken</h2>
<div style={{ marginBottom: "30px" }}>
<pre style={{ backgroundColor: "#3d3d3d", padding: "20px", borderRadius: "8px", overflow: "auto" }}>
<code className="language-yaml" style={{ color: "#f8f8f2" }}>
{`Performance:
- Bildverarbeitung: <50ms pro Frame
- API Response Time: p95 < 100ms
- System Uptime: 99.95%
- Datenverarbeitung: 500 Events/Sekunde
Skalierung:
- Kameras: 45 aktiv, bis 200 möglich
- IoT Sensoren: 200+
- Concurrent Users: 50+
- Datenspeicher: 50TB (3 Jahre Historie)
ML Performance:
- mAP@50: 0.92
- Inference Time: 23ms
- False Positive Rate: <2%
- Model Size: 138MB`}
</code>
</pre>
</div>
<h2 style={{ marginBottom: "20px" }}>Ergebnisse und Auswirkungen</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Quantitative Ergebnisse</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>98,7% Genauigkeit bei der Bestandserfassung</li>
<li>85% Reduktion der Inventurzeit</li>
<li>60% weniger Fehlbestände</li>
<li>ROI nach 14 Monaten erreicht</li>
<li>35% Steigerung der Lagerplatznutzung</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Qualitative Verbesserungen</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Echtzeit-Transparenz über alle Lagerbestände</li>
<li>Proaktive Nachbestellung verhindert Lieferengpässe</li>
<li>Mitarbeiter fokussieren sich auf wertschöpfende Tätigkeiten</li>
<li>Deutlich reduzierte Fehlerquote</li>
<li>Verbesserte Kundenzufriedenheit durch höhere Liefertreue</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Lessons Learned</h2>
<div style={{ marginBottom: "30px" }}>
<ol style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Edge Computing ist essentiell:</strong> Die Vorverarbeitung direkt an der Kamera reduzierte die Netzwerklast um 70%</li>
<li><strong>Datenqualität vor Quantität:</strong> 1.000 hochqualitative Annotationen waren wertvoller als 10.000 automatisch generierte</li>
<li><strong>Iterative Entwicklung:</strong> Frühe Pilotierung in einem Bereich ermöglichte schnelle Anpassungen</li>
<li><strong>Change Management:</strong> Frühzeitige Einbindung der Mitarbeiter war entscheidend für die Akzeptanz</li>
</ol>
</div>
<h2 style={{ marginBottom: "20px" }}>Zukunftsperspektiven</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
<strong>Nächste Entwicklungsstufen:</strong>
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Integration von Robotik für automatisierte Kommissionierung</li>
<li>Erweiterung auf Außenlager und mobile Einheiten</li>
<li>KI-basierte Vorhersage von Wartungsbedarf</li>
<li>Blockchain-Integration für Supply Chain Transparenz</li>
<li>AR-Brillen für Lagermitarbeiter mit visuellen Hinweisen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Dieses Projekt demonstriert die erfolgreiche Transformation traditioneller Lagerprozesse durch modernste Computer Vision und IoT-Technologien.
Die Kombination aus technischer Innovation und praxisorientierter Umsetzung schafft messbaren Mehrwert und ebnet den Weg für die Logistik 4.0.
Das System wurde als White-Label-Lösung konzipiert und kann mit minimalen Anpassungen in anderen Lagern eingesetzt werden.
</p>
</div>
@@ -0,0 +1,168 @@
---
slug: "website-mit-ki"
title: "Eigene Website mit KI bauen: Von Null zur fertigen Seite"
description: "Schritt-für-Schritt Anleitung zum Aufbau einer professionellen Website mit Claude Code - DSGVO-konform, mehrsprachig, mit Portfolio und Blog"
excerpt: "Wie du in unter einer Stunde eine komplette Website erstellst - ohne eine einzige Zeile Code selbst zu schreiben."
date: "2024-12-13"
category: "KI & Automatisierung"
coverImage: "/images/projects/website-mit-ki/cover.jpg"
client: "Eigenprojekt"
duration: "30 Minuten"
url: ""
repository: ""
documentation: ""
published: true
featured: true
technologies: ["Claude Code", "React", "TypeScript", "Vercel", "GitHub", "Vite"]
tags: ["KI", "Website", "No-Code", "Tutorial", "Claude Code", "Vercel"]
videoUrl: "https://www.tella.tv/video/baue-deine-website-noch-heute-mit-ki-8f7v"
---
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', marginBottom: '2rem', borderRadius: '12px' }}>
<iframe
src="https://www.tella.tv/video/baue-deine-website-noch-heute-mit-ki-8f7v/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0"
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allowFullScreen
loading="lazy"
/>
</div>
<div style={{ margin: "20px 0", padding: "20px", backgroundColor: "#3d3d3d", borderRadius: "8px" }}>
<p style={{ fontSize: "1.1rem", lineHeight: "1.6" }}>
Eine professionelle Website ohne Programmierkenntnisse? Mit KI ist das heute möglich. Dieses Tutorial zeigt den kompletten Prozess - von der leeren Projektmappe zur fertigen, gehosteten Website.
</p>
</div>
<hr style={{ margin: "40px 0", border: "none", height: "1px", backgroundColor: "#ddd" }} />
<h2 style={{ marginBottom: "20px" }}>Was wird gebaut?</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Die fertige Website enthält alle wichtigen Features einer modernen Webpräsenz:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>DSGVO-konforme Implementierung mit Cookie-Banner</li>
<li>Mehrsprachigkeit (Deutsch, Englisch, weitere möglich)</li>
<li>Über-mich-Seite für persönliche Informationen</li>
<li>Portfolio-Seite mit interaktiven Projekten</li>
<li>Blog-Funktionalität für Blogposts</li>
<li>SEO-Optimierung für bessere Google-Rankings</li>
<li>Kontaktformular für potenzielle Kunden</li>
<li>Mobile-optimiertes, responsives Design</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Die Vorteile</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Bessere Sichtbarkeit</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Durch SEO-Optimierung erscheint die Website direkt unter den ersten Google-Treffern für relevante Suchbegriffe - etwa bei der Suche nach dem eigenen Namen.
</p>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Minimale Kosten</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Im Gegensatz zu Baukastensystemen wie Wix, WordPress oder Webflow fallen keine monatlichen Gebühren an. Die einzigen Kosten: Ein KI-Abonnement und optional eine eigene Domain.
</p>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>Volle Kontrolle</h3>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Das Projekt liegt versioniert auf GitHub und wird über Vercel kostenlos gehostet. Keine Abhängigkeit von Plattformen, die ihre Preise ändern oder Features einschränken können.
</p>
</div>
<h2 style={{ marginBottom: "20px" }}>Die Werkzeuge</h2>
<div style={{ marginBottom: "30px" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "10px" }}>Tool</th>
<th style={{ textAlign: "left", padding: "10px" }}>Zweck</th>
<th style={{ textAlign: "left", padding: "10px" }}>Kosten</th>
</tr>
</thead>
<tbody>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Claude Code</strong></td>
<td style={{ padding: "10px" }}>KI für die komplette Entwicklung</td>
<td style={{ padding: "10px" }}>Pro-Plan empfohlen</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>GitHub</strong></td>
<td style={{ padding: "10px" }}>Versionierung des Codes</td>
<td style={{ padding: "10px" }}>Kostenlos</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Vercel</strong></td>
<td style={{ padding: "10px" }}>Hosting und Deployment</td>
<td style={{ padding: "10px" }}>Kostenlos</td>
</tr>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<td style={{ padding: "10px" }}><strong>Domain</strong></td>
<td style={{ padding: "10px" }}>Eigene URL</td>
<td style={{ padding: "10px" }}>Optional (~10-15EUR/Jahr)</td>
</tr>
</tbody>
</table>
</div>
<h2 style={{ marginBottom: "20px" }}>Der Workflow</h2>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>1. Vorbereitung</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>GitHub-Account erstellen und neue Repository anlegen</li>
<li>Vercel-Account erstellen für späteres Hosting</li>
<li>Lokalen Projektordner auf dem Rechner anlegen</li>
<li>Relevante Dokumente bereitstellen (Lebenslauf, Anschreiben, etc.)</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>2. Entwicklung mit Claude Code</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Claude Code öffnen und Projektordner auswählen</li>
<li>Prompt mit gewünschten Features eingeben</li>
<li>KI arbeitet autonom durch alle Dateien und erstellt die Website</li>
<li>Berechtigungen bei Nachfrage erteilen</li>
</ul>
<h3 style={{ marginTop: "20px", marginBottom: "15px" }}>3. Deployment</h3>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Projekt auf GitHub pushen</li>
<li>In Vercel das GitHub-Repository importieren</li>
<li>Website wird automatisch gebaut und deployed</li>
<li>Bei Build-Fehlern: Logs kopieren und Claude zur Fehlerbehebung nutzen</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Design-Inspiration</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Für individuelle Designs gibt es mehrere Ansätze:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li><strong>Awwwards:</strong> Preisgekrönte Websites als Inspiration</li>
<li><strong>Envato Elements:</strong> Website-Templates als Designvorlage - Screenshot anfertigen und Claude zeigen</li>
<li><strong>Individuelle Anpassungen:</strong> Farben, Bilder und Elemente per Chat-Anweisung ändern</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Änderungen vornehmen</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Nach dem initialen Setup können jederzeit Änderungen vorgenommen werden:
</p>
<ul style={{ marginLeft: "20px", fontSize: "1rem", lineHeight: "1.6" }}>
<li>Bilder austauschen durch Anhängen in Claude Code</li>
<li>Texte und Inhalte per Chat-Anweisung ändern</li>
<li>Neue Seiten oder Funktionen hinzufügen</li>
<li>Nach jeder Änderung: Push zu GitHub, automatisches Redeployment auf Vercel</li>
</ul>
</div>
<h2 style={{ marginBottom: "20px" }}>Fazit</h2>
<div style={{ marginBottom: "30px" }}>
<p style={{ fontSize: "1rem", lineHeight: "1.6" }}>
Eine komplette, professionelle Website lässt sich heute in unter einer Stunde aufbauen - ohne eine einzige Zeile Code selbst zu schreiben. Die Kombination aus KI-Entwicklung, kostenlosem Hosting und Versionierung macht diese Lösung nicht nur für Entwickler interessant, sondern für alle, die eine professionelle Webpräsenz benötigen: Selbstständige, Unternehmen oder Projekte.
</p>
<p style={{ fontSize: "1rem", lineHeight: "1.6", marginTop: "15px" }}>
Der Kostenfaktor ist minimal, die Kontrolle maximal - und das Ergebnis kann sich mit jeder professionell entwickelten Website messen.
</p>
</div>
+18
View File
@@ -0,0 +1,18 @@
// pages/portfolio/types.ts
import { ReactNode } from 'react';
export interface Project {
title: string;
description: string;
image: string;
tags: string[];
slug: string;
date: string;
content: ReactNode;
client?: string;
technologies?: string[];
links?: {
github?: string;
live?: string;
};
}
+205
View File
@@ -0,0 +1,205 @@
// src/pages/portfolio/utils.ts
import { type ComponentType } from 'react';
export interface Project {
slug: string;
title: string;
description: string;
excerpt: string;
date: string;
category: string;
coverImage: string;
client: string;
duration: string;
url?: string;
repository?: string;
documentation?: string;
published: boolean;
featured: boolean;
technologies: string[];
tags: string[];
}
export type ProjectWithContent = Project & {
content: ComponentType;
};
interface ProjectModule {
frontmatter: unknown;
default: ComponentType;
}
/**
* Validiert, ob das übergebene Frontmatter den Anforderungen eines Project entspricht.
*/
const validateProjectFrontmatter = (frontmatter: unknown): frontmatter is Project => {
if (!frontmatter || typeof frontmatter !== 'object') return false;
const fm = frontmatter as Record<string, unknown>;
const requiredFields: (keyof Project)[] = [
'slug',
'title',
'description',
'excerpt',
'date',
'category',
'coverImage',
'client',
'duration',
'published',
'featured',
'technologies',
'tags'
];
const missingFields = requiredFields.filter(field => !(field in fm));
if (missingFields.length > 0) {
console.warn(`Missing required fields in project frontmatter: ${missingFields.join(', ')}`);
return false;
}
// Validierungen: Bei Feldern, die Arrays sein sollen, prüfen wir, ob jedes Element vom erwarteten Typ ist.
const validations = [
{ field: 'slug', type: 'string' },
{ field: 'title', type: 'string' },
{ field: 'description', type: 'string' },
{ field: 'excerpt', type: 'string' },
{ field: 'date', type: 'string' },
{ field: 'category', type: 'string' },
{ field: 'coverImage', type: 'string' },
{ field: 'client', type: 'string' },
{ field: 'duration', type: 'string' },
{ field: 'published', type: 'boolean' },
{ field: 'featured', type: 'boolean' },
{ field: 'technologies', type: 'object', arrayOf: 'string' },
{ field: 'tags', type: 'object', arrayOf: 'string' }
];
for (const validation of validations) {
const value = fm[validation.field];
if (validation.type === 'object' && validation.arrayOf) {
if (!Array.isArray(value) || !value.every(item => typeof item === validation.arrayOf)) {
console.warn(`Invalid type for ${validation.field}. Expected array of ${validation.arrayOf}`);
return false;
}
} else if (typeof value !== validation.type) {
console.warn(`Invalid type for ${validation.field}. Expected ${validation.type}`);
return false;
}
}
return true;
};
export const getAllProjects = async (): Promise<Project[]> => {
try {
const modules = import.meta.glob('./projects/*.mdx', { eager: true }) as Record<string, ProjectModule>;
console.log('Found project files:', Object.keys(modules));
const projects = Object.entries(modules)
.map(([path, module]: [string, ProjectModule]) => {
console.log('Processing:', path);
const frontmatter = module.frontmatter;
if (!validateProjectFrontmatter(frontmatter)) {
console.warn(`Invalid or missing frontmatter in project: ${path}`);
return null;
}
const fm = frontmatter as Project;
// Cast fm zuerst zu unknown und dann zu Record<string, unknown>
const fmAny = fm as unknown as Record<string, unknown>;
if ('image' in fmAny && !fm.coverImage) {
fm.coverImage = fmAny['image'] as string;
delete fmAny['image'];
}
return fm;
})
.filter((project): project is Project => project !== null && project.published);
// Sortiere nach Datum absteigend (neueste zuerst)
return projects.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
} catch (error) {
console.error('Error loading projects:', error);
return [];
}
};
export const getFeaturedProjects = async (): Promise<Project[]> => {
const projects = await getAllProjects();
return projects.filter(project => project.featured);
};
export const getProjectsByCategory = async (category: string): Promise<Project[]> => {
const projects = await getAllProjects();
return projects.filter(project => project.category === category);
};
export const getProjectBySlug = async (slug: string): Promise<ProjectWithContent | null> => {
if (!slug) return null;
try {
const modules = import.meta.glob('./projects/*.mdx', { eager: true }) as Record<string, ProjectModule>;
const projectEntry = Object.entries(modules).find(([path]) =>
path.includes(`${slug}.mdx`)
);
if (!projectEntry) {
console.warn(`Project not found: ${slug}`);
return null;
}
const module = projectEntry[1];
const frontmatter = module.frontmatter;
if (!validateProjectFrontmatter(frontmatter)) {
console.warn(`Invalid frontmatter in project: ${slug}`);
return null;
}
const fm = frontmatter as Project;
if (!fm.published) {
console.warn(`Attempting to access unpublished project: ${slug}`);
return null;
}
const fmAny = fm as unknown as Record<string, unknown>;
if ('image' in fmAny && !fm.coverImage) {
fm.coverImage = fmAny['image'] as string;
delete fmAny['image'];
}
return {
...fm,
content: module.default
};
} catch (error) {
console.error(`Error loading project: ${slug}`, error);
return null;
}
};
export const getRelatedProjects = async (
currentSlug: string,
tags: string[],
limit: number = 3
): Promise<Project[]> => {
const allProjects = await getAllProjects();
return allProjects
.filter(project =>
project.slug !== currentSlug &&
project.tags.some(tag => tags.includes(tag))
)
.sort((a, b) => {
const aMatches = a.tags.filter(tag => tags.includes(tag)).length;
const bMatches = b.tags.filter(tag => tags.includes(tag)).length;
return bMatches - aMatches;
})
.slice(0, limit);
};
export const getAllProjectCategories = async (): Promise<string[]> => {
const projects = await getAllProjects();
const categories = new Set(projects.map(project => project.category));
return Array.from(categories).sort();
};