auto-claude: subtask-2-2 - Delete src/pages-vite directory (454KB, 50 files)
This commit is contained in:
+3
-3
@@ -3,7 +3,7 @@
|
||||
"spec": "029-remove-dead-code-components-vite-and-pages-vite-fo",
|
||||
"state": "building",
|
||||
"subtasks": {
|
||||
"completed": 2,
|
||||
"completed": 3,
|
||||
"total": 8,
|
||||
"in_progress": 1,
|
||||
"failed": 0
|
||||
@@ -18,8 +18,8 @@
|
||||
"max": 1
|
||||
},
|
||||
"session": {
|
||||
"number": 4,
|
||||
"number": 5,
|
||||
"started_at": "2026-01-25T06:18:19.396966"
|
||||
},
|
||||
"last_update": "2026-01-25T06:27:40.436575"
|
||||
"last_update": "2026-01-25T06:28:41.216540"
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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;
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
@@ -1,99 +0,0 @@
|
||||
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;
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
@@ -1,199 +0,0 @@
|
||||
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;
|
||||
@@ -1,81 +0,0 @@
|
||||
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;
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
@@ -1,156 +0,0 @@
|
||||
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;
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
@@ -1,519 +0,0 @@
|
||||
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;
|
||||
@@ -1,196 +0,0 @@
|
||||
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;
|
||||
@@ -1,185 +0,0 @@
|
||||
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;
|
||||
@@ -1,229 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,303 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,231 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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;
|
||||
@@ -1,137 +0,0 @@
|
||||
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;
|
||||
@@ -1,109 +0,0 @@
|
||||
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;
|
||||
@@ -1,134 +0,0 @@
|
||||
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;
|
||||
@@ -1,205 +0,0 @@
|
||||
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;
|
||||
@@ -1,90 +0,0 @@
|
||||
"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;
|
||||
@@ -1,158 +0,0 @@
|
||||
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;
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
@@ -1,182 +0,0 @@
|
||||
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;
|
||||
@@ -1,115 +0,0 @@
|
||||
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;
|
||||
@@ -1,417 +0,0 @@
|
||||
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;
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,169 +0,0 @@
|
||||
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;
|
||||
@@ -1,145 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,121 +0,0 @@
|
||||
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;
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
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 > 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>
|
||||
@@ -1,229 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,320 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,270 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
Reference in New Issue
Block a user