Merge pull request #2 from damjan1996/auto-claude/029-remove-dead-code-components-vite-and-pages-vite-fo
auto-claude: 029-remove-dead-code-components-vite-and-pages-vite-fo
This commit is contained in:
@@ -1,43 +0,0 @@
|
||||
// Alert.tsx
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
interface AlertProps {
|
||||
variant?: 'success' | 'error';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Alert: React.FC<AlertProps> = ({ variant = 'success', children }) => {
|
||||
const variants = {
|
||||
success: {
|
||||
bg: 'bg-primary/10',
|
||||
border: 'border-primary',
|
||||
text: 'text-primary',
|
||||
icon: <CheckCircle2 className="h-5 w-5" />
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
text: 'text-red-400',
|
||||
icon: <XCircle className="h-5 w-5" />
|
||||
}
|
||||
};
|
||||
|
||||
const style = variants[variant];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`${style.bg} ${style.border} ${style.text} border rounded-lg p-4 flex items-start space-x-3`}
|
||||
>
|
||||
<span className="flex-shrink-0">{style.icon}</span>
|
||||
<div className="flex-1">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertDescription: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <div className="text-sm">{children}</div>;
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
currentTitle?: string;
|
||||
}
|
||||
|
||||
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ currentTitle }) => {
|
||||
const location = useLocation();
|
||||
|
||||
// Definiere die feste Struktur der Breadcrumbs
|
||||
const generateBreadcrumbs = () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Basis-Breadcrumb-Struktur
|
||||
const breadcrumbs = [];
|
||||
|
||||
// Portfolio ist immer der erste Level nach Home
|
||||
if (segments.includes('portfolio')) {
|
||||
breadcrumbs.push({
|
||||
title: 'Portfolio',
|
||||
path: '/portfolio',
|
||||
isLast: segments.length === 1
|
||||
});
|
||||
}
|
||||
|
||||
// Wenn wir in einem Projekt sind, füge den Projekttitel hinzu
|
||||
if (segments.length > 1 && currentTitle) {
|
||||
breadcrumbs.push({
|
||||
title: currentTitle,
|
||||
path: location.pathname,
|
||||
isLast: true
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs();
|
||||
|
||||
// Wenn wir nur auf der Homepage sind, zeigen wir keine Breadcrumbs
|
||||
if (location.pathname === '/') return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 py-4 sm:py-6">
|
||||
{/* Home ist immer der erste Link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<React.Fragment key={crumb.path}>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600" />
|
||||
|
||||
{crumb.isLast ? (
|
||||
<span className="text-white font-medium">
|
||||
{crumb.title}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={crumb.path}
|
||||
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{crumb.title}
|
||||
</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
||||
@@ -1,60 +0,0 @@
|
||||
// Button.tsx
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isLoading?: boolean;
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
isLoading,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary text-white hover:bg-primary/90',
|
||||
outline: 'border border-primary text-primary hover:bg-primary hover:text-white',
|
||||
ghost: 'text-primary hover:bg-primary/10'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-9 px-4 text-sm',
|
||||
md: 'h-12 px-6 text-base',
|
||||
lg: 'h-14 px-8 text-lg'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={isLoading || disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export function ContactBar() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50 }}
|
||||
animate={{ y: 0 }}
|
||||
className="bg-gradient-to-r from-cyan-900 to-slate-900 text-white py-2 px-4"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<a href="tel:+1234567890" className="flex items-center space-x-2 hover:text-cyan-400 transition-colors">
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>+1 234 567 890</span>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="flex items-center space-x-2 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>info@damjan-savic.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<Link to="/contact" className="flex items-center space-x-1 text-sm hover:text-cyan-400 transition-colors group">
|
||||
<span>Contact Us</span>
|
||||
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Send, AlertCircle } from 'lucide-react';
|
||||
import { getCsrfToken } from '../utils/csrf';
|
||||
import { rateLimiter } from '../utils/rateLimiting';
|
||||
|
||||
interface ContactFormProps {
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
const ContactForm: React.FC<ContactFormProps> = ({ onSubmit }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check rate limiting
|
||||
const clientIp = '127.0.0.1'; // In production, get this from the request
|
||||
if (rateLimiter.isRateLimited(clientIp)) {
|
||||
const timeToReset = Math.ceil(rateLimiter.getTimeToReset(clientIp) / 1000 / 60);
|
||||
throw new Error(`Too many attempts. Please try again in ${timeToReset} minutes.`);
|
||||
}
|
||||
|
||||
// Validate CSRF token
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('email', email);
|
||||
formData.append('message', message);
|
||||
formData.append('csrf_token', getCsrfToken());
|
||||
|
||||
await onSubmit(formData);
|
||||
setSuccess(true);
|
||||
setName('');
|
||||
setEmail('');
|
||||
setMessage('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 border border-green-500/20 text-green-500 p-4 rounded-lg">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-white/80 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
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"
|
||||
required
|
||||
></textarea>
|
||||
</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 flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Sending...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center">
|
||||
<Send className="h-5 w-5 mr-2" />
|
||||
Send Message
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export function ContactInfo() {
|
||||
return (
|
||||
<div className="pb-6 px-6 pt-4 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mt-2 animate-fade-in">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-white tracking-wider uppercase">
|
||||
Kontakt
|
||||
</h3>
|
||||
|
||||
<a
|
||||
href="tel:+1234567890"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>+1 234 567 890</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>info@damjan-savic.com</span>
|
||||
</a>
|
||||
|
||||
<Link
|
||||
to="/contact"
|
||||
className="flex items-center justify-between mt-6 px-4 py-2 bg-zinc-800/50
|
||||
hover:bg-zinc-800 rounded-full text-sm text-zinc-400 hover:text-white
|
||||
transition-all duration-200 group border border-zinc-800 hover:border-zinc-700"
|
||||
>
|
||||
<span className="tracking-wide">Kontakt aufnehmen</span>
|
||||
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { initGA, logPageView, initFBPixel, initFunctionalCookies } from '../utils/analytics';
|
||||
import { loadGoogleTagManager } from '../services/gtm';
|
||||
|
||||
// Feste Übersetzungen direkt in der Komponente
|
||||
const translations = {
|
||||
de: {
|
||||
title: "Cookie-Einstellungen",
|
||||
message: "Wir nutzen Cookies zur Verbesserung der Website. Ohne Ihre Zustimmung keine Datenweitergabe an Dritte. ",
|
||||
acceptAll: "Alle akzeptieren",
|
||||
save: "Einstellungen speichern",
|
||||
decline: "Ablehnen",
|
||||
necessary: "Notwendige Cookies",
|
||||
necessaryDesc: "Für grundlegende Funktionen der Website",
|
||||
analytics: "Analyse-Cookies",
|
||||
analyticsDesc: "Für Analysen zur Verbesserung der Website",
|
||||
marketing: "Marketing-Cookies",
|
||||
marketingDesc: "Für personalisierte Werbung und Inhalte",
|
||||
functional: "Funktionale Cookies",
|
||||
functionalDesc: "Für erweiterte Funktionen und Personalisierung",
|
||||
learnMore: "Mehr erfahren",
|
||||
alwaysActive: "Immer aktiv"
|
||||
},
|
||||
en: {
|
||||
title: "Cookie Settings",
|
||||
message: "This website uses cookies to enhance your browsing experience. Data will not be shared with third parties without your consent.",
|
||||
acceptAll: "Accept All",
|
||||
save: "Save Settings",
|
||||
decline: "Decline All",
|
||||
necessary: "Necessary Cookies",
|
||||
necessaryDesc: "For basic website functions",
|
||||
analytics: "Analytics Cookies",
|
||||
analyticsDesc: "For analyzing website usage",
|
||||
marketing: "Marketing Cookies",
|
||||
marketingDesc: "For personalized ads and content",
|
||||
functional: "Functional Cookies",
|
||||
functionalDesc: "For enhanced functionality and personalization",
|
||||
learnMore: "Learn more",
|
||||
alwaysActive: "Always active"
|
||||
},
|
||||
sr: {
|
||||
title: "Podešavanja kolačića",
|
||||
message: "Ovaj sajt koristi kolačiće za bolje iskustvo pretraživanja. Podaci neće biti deljeni sa trećim licima bez vaše saglasnosti.",
|
||||
acceptAll: "Prihvati sve",
|
||||
save: "Sačuvaj podešavanja",
|
||||
decline: "Odbij sve",
|
||||
necessary: "Neophodni kolačići",
|
||||
necessaryDesc: "Za osnovne funkcije sajta",
|
||||
analytics: "Analitički kolačići",
|
||||
analyticsDesc: "Za analizu korišćenja sajta",
|
||||
marketing: "Marketing kolačići",
|
||||
marketingDesc: "Za personalizovane reklame i sadržaj",
|
||||
functional: "Funkcionalni kolačići",
|
||||
functionalDesc: "Za napredne funkcije i personalizaciju",
|
||||
learnMore: "Saznajte više",
|
||||
alwaysActive: "Uvek aktivno"
|
||||
}
|
||||
};
|
||||
|
||||
// Definition der verschiedenen Services
|
||||
const cookieServices = {
|
||||
essentiell: {
|
||||
category: 'necessary',
|
||||
services: [
|
||||
{
|
||||
name: 'Session Cookies',
|
||||
provider: 'Eigentümer der Website',
|
||||
purpose: 'Speichert Ihre Sitzungsinformationen'
|
||||
}
|
||||
]
|
||||
},
|
||||
funktional: {
|
||||
category: 'functional',
|
||||
services: [
|
||||
{
|
||||
name: 'Spracheinstellungen',
|
||||
provider: 'Eigentümer der Website',
|
||||
purpose: 'Speichert Ihre bevorzugte Sprache'
|
||||
},
|
||||
{
|
||||
name: 'Google Fonts',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Anzeige von Webschriften'
|
||||
}
|
||||
]
|
||||
},
|
||||
analyse: {
|
||||
category: 'analytics',
|
||||
services: [
|
||||
{
|
||||
name: 'Google Analytics',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Analysiert Webseitennutzung und Nutzerverhalten'
|
||||
}
|
||||
]
|
||||
},
|
||||
marketing: {
|
||||
category: 'marketing',
|
||||
services: [
|
||||
{
|
||||
name: 'Google Ads',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Personalisierte Werbung'
|
||||
},
|
||||
{
|
||||
name: 'Facebook Pixel',
|
||||
provider: 'Meta Platforms Inc.',
|
||||
purpose: 'Tracking von Werbekonversionen'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface CookieSettings {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
}
|
||||
|
||||
// Entfernt externe Google Fonts, falls keine Zustimmung vorliegt
|
||||
const removeExternalGoogleFonts = () => {
|
||||
// Suche nach allen Google Fonts Link-Elementen und entferne sie
|
||||
const linkElements = document.querySelectorAll('link[href*="fonts.googleapis.com"]');
|
||||
linkElements.forEach(link => {
|
||||
link.parentNode?.removeChild(link);
|
||||
});
|
||||
|
||||
// Suche nach allen Google Fonts Script-Elementen und entferne sie
|
||||
const scriptElements = document.querySelectorAll('script[src*="fonts.googleapis.com"]');
|
||||
scriptElements.forEach(script => {
|
||||
script.parentNode?.removeChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
// Funktion zum Blockieren von Google Analytics falls noch keine Zustimmung vorliegt
|
||||
const blockGoogleAnalytics = () => {
|
||||
// Entferne alle bestehenden GA Cookies
|
||||
document.cookie.split(';').forEach(function(c) {
|
||||
if (c.trim().indexOf('_ga') === 0) {
|
||||
document.cookie = c.trim().split('=')[0] + '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const CookieBanner = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [currentLang, setCurrentLang] = useState<'de' | 'en' | 'sr'>('de');
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [cookieSettings, setCookieSettings] = useState<CookieSettings>({
|
||||
necessary: true, // Always true and disabled
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false
|
||||
});
|
||||
|
||||
// Initialisiere Dienste basierend auf den Cookie-Einstellungen
|
||||
const initializeServices = useCallback((settings: CookieSettings) => {
|
||||
// Blockieren von Diensten, wenn nicht zugestimmt wurde
|
||||
if (!settings.functional) {
|
||||
removeExternalGoogleFonts();
|
||||
}
|
||||
|
||||
if (!settings.analytics) {
|
||||
blockGoogleAnalytics();
|
||||
} else {
|
||||
// Initialisiere Google Analytics nur wenn ausdrücklich zugestimmt wurde
|
||||
initGA();
|
||||
logPageView();
|
||||
}
|
||||
|
||||
// Marketing-Dienste nur mit Zustimmung
|
||||
if (settings.marketing) {
|
||||
initFBPixel();
|
||||
loadGoogleTagManager();
|
||||
}
|
||||
|
||||
// Funktionale Dienste nur mit Zustimmung
|
||||
if (settings.functional) {
|
||||
initFunctionalCookies();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Aktuelle Sprache erkennen
|
||||
useEffect(() => {
|
||||
const lang = i18n.language.substring(0, 2);
|
||||
if (lang === 'de' || lang === 'en' || lang === 'sr') {
|
||||
setCurrentLang(lang);
|
||||
} else {
|
||||
setCurrentLang('en'); // Fallback to English
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
// Get text based on current language
|
||||
const getText = (key: keyof typeof translations.en) => {
|
||||
return translations[currentLang][key];
|
||||
};
|
||||
|
||||
// DSGVO: Unmittelbar beim ersten Laden blockiere alle nicht-essentiellen Dienste
|
||||
useEffect(() => {
|
||||
// Blockiere externe Dienste bis Zustimmung vorliegt
|
||||
removeExternalGoogleFonts();
|
||||
blockGoogleAnalytics();
|
||||
}, []);
|
||||
|
||||
// Prüfe beim Laden, ob bereits Zustimmung vorhanden ist
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedSettings = localStorage.getItem('cookieSettings');
|
||||
const hasConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (storedSettings && hasConsent) {
|
||||
const parsedSettings = JSON.parse(storedSettings) as CookieSettings;
|
||||
setCookieSettings(parsedSettings);
|
||||
|
||||
// Lade die entsprechenden Dienste basierend auf den Einstellungen
|
||||
initializeServices(parsedSettings);
|
||||
setShowBanner(false);
|
||||
} else {
|
||||
// Zeige Banner wenn keine Zustimmung vorhanden ist
|
||||
setShowBanner(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cookie settings:', error);
|
||||
setShowBanner(true);
|
||||
}
|
||||
}, [initializeServices]);
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(cookieSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
|
||||
// Initialisiere Dienste basierend auf den ausgewählten Einstellungen
|
||||
initializeServices(cookieSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
const allSettings: CookieSettings = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true
|
||||
};
|
||||
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(allSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setCookieSettings(allSettings);
|
||||
|
||||
// Initialisiere alle Dienste
|
||||
initializeServices(allSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const handleDeclineAll = () => {
|
||||
const minimalSettings: CookieSettings = {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false
|
||||
};
|
||||
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(minimalSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setCookieSettings(minimalSettings);
|
||||
|
||||
// Block all non-essential services
|
||||
initializeServices(minimalSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const toggleSetting = (setting: keyof Omit<CookieSettings, 'necessary'>) => {
|
||||
setCookieSettings({
|
||||
...cookieSettings,
|
||||
[setting]: !cookieSettings[setting]
|
||||
});
|
||||
};
|
||||
|
||||
// If banner should not be shown, return null
|
||||
if (!showBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||
<div
|
||||
className="bg-[rgba(18,18,18,0.95)] backdrop-blur-md border-t border-white/5 text-white p-4 md:p-6"
|
||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{!showSettings ? (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm md:text-base font-light">{getText('message')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 mt-3 md:mt-0 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light bg-white text-black hover:bg-gray-200 transition-all"
|
||||
>
|
||||
{getText('acceptAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">{getText('title')}</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-white/80 mb-4">{getText('message')}</p>
|
||||
|
||||
{/* Cookie categories */}
|
||||
<div className="space-y-4">
|
||||
{/* Necessary cookies - always enabled */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('necessary')}</div>
|
||||
<div className="text-xs text-white/60">{getText('alwaysActive')}</div>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('necessaryDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.essentiell.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('analytics')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.analytics}
|
||||
onChange={() => toggleSetting('analytics')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('analyticsDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.analyse.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('marketing')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.marketing}
|
||||
onChange={() => toggleSetting('marketing')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('marketingDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.marketing.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Functional cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('functional')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.functional}
|
||||
onChange={() => toggleSetting('functional')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('functionalDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.funktional.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
className="px-4 py-2 text-xs uppercase tracking-wider font-light bg-white text-black hover:bg-gray-200 transition-all"
|
||||
>
|
||||
{getText('save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center space-x-4">
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-xs text-white/70 hover:text-white underline"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</a>
|
||||
<a
|
||||
href="/imprint"
|
||||
className="text-xs text-white/70 hover:text-white underline"
|
||||
>
|
||||
Impressum
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useScrollContext } from './ScrollContext';
|
||||
|
||||
interface KeyboardEvent {
|
||||
key: string;
|
||||
}
|
||||
|
||||
const DebugOverlay: React.FC = () => {
|
||||
const [showDebug, setShowDebug] = useState<boolean>(false);
|
||||
const { isTransitioning } = useScrollContext();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'd') {
|
||||
setShowDebug((prev: boolean) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, []);
|
||||
|
||||
if (!showDebug) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg z-[100] font-mono text-sm">
|
||||
<div>Transitioning: {isTransitioning ? 'Yes' : 'No'}</div>
|
||||
<div>Body Overflow: {document.body.style.overflow}</div>
|
||||
<div>Scroll Position: {window.scrollY}</div>
|
||||
<div>View Height: {window.innerHeight}</div>
|
||||
<button
|
||||
onClick={() => document.body.style.overflow = ''}
|
||||
className="bg-blue-500 px-2 py-1 rounded mt-2"
|
||||
>
|
||||
Reset Overflow
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugOverlay;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
|
||||
<p className="text-white/70 mb-6">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trackFAQInteraction } from '../services/gtm';
|
||||
|
||||
export function FAQSection() {
|
||||
const { t } = useTranslation();
|
||||
const [openItem, setOpenItem] = useState<number | null>(null);
|
||||
|
||||
const faqs = t('faq.questions', { returnObjects: true }) as Array<{
|
||||
question: string;
|
||||
answer: string;
|
||||
category?: string;
|
||||
}>;
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
const isOpening = openItem !== index;
|
||||
const question = faqs[index]?.question || '';
|
||||
|
||||
// Track FAQ interaction
|
||||
trackFAQInteraction(question, isOpening ? 'open' : 'close');
|
||||
|
||||
setOpenItem(prev => prev === index ? null : index);
|
||||
};
|
||||
|
||||
const faqSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": faqs.map(faq => ({
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="faq-section py-16 px-4 max-w-4xl mx-auto">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
|
||||
<h2 className="text-3xl font-bold text-white mb-8 text-center">
|
||||
{t('faq.title')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="faq-item bg-zinc-800/50 border border-zinc-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-zinc-800/70 transition-colors"
|
||||
aria-expanded={openItem === index}
|
||||
aria-controls={`faq-answer-${index}`}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white pr-4">
|
||||
{faq.question}
|
||||
</h3>
|
||||
{openItem === index ? (
|
||||
<ChevronUp className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`faq-answer-${index}`}
|
||||
className={`overflow-hidden transition-all duration-300 ${
|
||||
openItem === index ? 'max-h-96' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-4 text-zinc-300 border-t border-zinc-700">
|
||||
<p>{faq.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-zinc-400">
|
||||
{t('faq.moreQuestions')}{' '}
|
||||
<a
|
||||
href="/contact"
|
||||
className="text-white hover:text-zinc-300 underline transition-colors"
|
||||
>
|
||||
{t('faq.contactUs')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export function FloatingPaths({ position }: { position: number }) {
|
||||
const paths = Array.from({ length: 36 }, (_, i) => ({
|
||||
id: i,
|
||||
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
|
||||
380 - i * 5 * position
|
||||
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
|
||||
152 - i * 5 * position
|
||||
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
|
||||
684 - i * 5 * position
|
||||
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
|
||||
color: `rgba(var(--accent),${0.1 + i * 0.01})`,
|
||||
width: 0.5 + i * 0.03,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none floating-paths">
|
||||
<svg className="w-full h-full text-accent" viewBox="0 0 696 316" fill="none" style={{ transform: 'translateZ(0)' }}>
|
||||
<title>Background Paths</title>
|
||||
{paths.map((path) => (
|
||||
<motion.path
|
||||
key={path.id}
|
||||
d={path.d}
|
||||
stroke="currentColor"
|
||||
strokeWidth={path.width}
|
||||
strokeOpacity={0.1 + path.id * 0.01}
|
||||
initial={{ pathLength: 0.3, opacity: 0.6 }}
|
||||
animate={{
|
||||
pathLength: 1,
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
pathOffset: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20 + Math.random() * 10,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
}}
|
||||
style={{ willChange: 'auto' }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// components/Footer.tsx
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mail, Linkedin, Github, MapPin } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative bg-zinc-800/50 backdrop-blur-sm border-t border-zinc-700/30 pt-8 pb-4 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/* Contact Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3">
|
||||
{t('footer.sections.contact.title')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`mailto:${t('footer.sections.contact.email')}`}
|
||||
className="group flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm !p-0 !min-h-0 !min-w-0 w-fit"
|
||||
aria-label={t('footer.sections.contact.aria.emailLink')}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors flex-shrink-0" size={16} />
|
||||
<span>{t('footer.sections.contact.email')}</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm w-fit">
|
||||
<MapPin className="h-4 w-4 text-zinc-500 flex-shrink-0" size={16} />
|
||||
<span>{t('footer.sections.contact.location')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('footer.sections.navigation.title')}
|
||||
</h3>
|
||||
<nav className="flex flex-col space-y-2">
|
||||
<Link
|
||||
to="/portfolio"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.portfolio')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.contact')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Social Media Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('footer.sections.social.title')}
|
||||
</h3>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/damjan-savić-720288127/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
aria-label={t('footer.sections.social.aria.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 !p-0 !min-h-0 !min-w-0"
|
||||
aria-label={t('footer.sections.social.aria.github')}
|
||||
>
|
||||
<Github className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Section */}
|
||||
<div className="border-t border-zinc-700/30 pt-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="text-zinc-400 text-xs text-left">
|
||||
© {currentYear} Damjan Savić. {t('footer.legal.copyright')}
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/imprint"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.imprint')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.terms')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
|
||||
// Lazy load FloatingPaths - it's just decoration and shouldn't block initial render
|
||||
const FloatingPaths = lazy(() => import('./FloatingPaths').then(m => ({ default: m.FloatingPaths })));
|
||||
|
||||
const GlobalBackground = () => {
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [showPaths, setShowPaths] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrollPosition(window.scrollY * 0.001);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Delay loading FloatingPaths until after initial render
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowPaths(true), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background layer - behind everything */}
|
||||
<div
|
||||
className="fixed inset-0 bg-zinc-900"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
background: 'linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 50%, #1e1e1e 100%)',
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FloatingPaths layer - lazy loaded after 2s */}
|
||||
{showPaths && (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<FloatingPaths position={scrollPosition} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simple animated lines with CSS */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
style={{ opacity: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1="0%"
|
||||
y1="20%"
|
||||
x2="100%"
|
||||
y2="20%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
>
|
||||
<animate
|
||||
attributeName="y1"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y2"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="0%"
|
||||
y1="50%"
|
||||
x2="100%"
|
||||
y2="50%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.15"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="0%;100%;0%"
|
||||
dur="15s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="20%"
|
||||
y2="100%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.1"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x2"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalBackground;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function HreflangTags() {
|
||||
const { i18n } = useTranslation();
|
||||
const location = window.location;
|
||||
const currentPath = location.pathname;
|
||||
|
||||
const siteUrl = 'https://damjan-savic.com';
|
||||
const supportedLanguages = ['de', 'en', 'sr'];
|
||||
const defaultLanguage = 'de';
|
||||
|
||||
// Extract current language from URL
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
const currentLang = supportedLanguages.includes(pathSegments[0]) ? pathSegments[0] : defaultLanguage;
|
||||
|
||||
// Get path without language prefix
|
||||
const pathWithoutLang = currentLang === defaultLanguage
|
||||
? currentPath
|
||||
: currentPath.replace(`/${currentLang}`, '') || '/';
|
||||
|
||||
// Generate hreflang URLs
|
||||
const generateHreflangUrl = (lang: string) => {
|
||||
if (lang === defaultLanguage) {
|
||||
return `${siteUrl}${pathWithoutLang}`;
|
||||
}
|
||||
return `${siteUrl}/${lang}${pathWithoutLang}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Hreflang tags for all supported languages */}
|
||||
{supportedLanguages.map((lang) => (
|
||||
<link
|
||||
key={lang}
|
||||
rel="alternate"
|
||||
hrefLang={lang}
|
||||
href={generateHreflangUrl(lang)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* x-default hreflang */}
|
||||
<link
|
||||
rel="alternate"
|
||||
hrefLang="x-default"
|
||||
href={generateHreflangUrl(defaultLanguage)}
|
||||
/>
|
||||
|
||||
{/* Additional language meta tags */}
|
||||
<html lang={currentLang} />
|
||||
<meta property="og:locale" content={currentLang === 'de' ? 'de_DE' : currentLang === 'en' ? 'en_US' : 'sr_RS'} />
|
||||
|
||||
{/* Alternate og:locale tags */}
|
||||
{supportedLanguages
|
||||
.filter(lang => lang !== currentLang)
|
||||
.map(lang => (
|
||||
<meta
|
||||
key={`og-locale-${lang}`}
|
||||
property="og:locale:alternate"
|
||||
content={lang === 'de' ? 'de_DE' : lang === 'en' ? 'en_US' : 'sr_RS'}
|
||||
/>
|
||||
))}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
|
||||
declare module 'react' {
|
||||
interface ImgHTMLAttributes<T> extends React.HTMLAttributes<T> {
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageWithFallbackProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
|
||||
const ImageWithFallback: React.FC<ImageWithFallbackProps> = ({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
sizes = '100vw',
|
||||
loading = 'lazy',
|
||||
fetchpriority = 'auto'
|
||||
}) => {
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-gray-900 ${className}`}>
|
||||
<div className="text-center p-4">
|
||||
<ImageOff className="h-8 w-8 mx-auto mb-2 text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{alt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isLoading && (
|
||||
<div className={`absolute inset-0 bg-gray-900/50 animate-pulse ${className}`} />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${className} ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
|
||||
loading={loading}
|
||||
fetchpriority={fetchpriority}
|
||||
sizes={sizes}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => setError(true)}
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageWithFallback;
|
||||
@@ -1,25 +0,0 @@
|
||||
// Input.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
className={`w-full h-12 bg-zinc-900/50 border border-white/10 rounded-lg px-4 text-white
|
||||
placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary transition-all
|
||||
disabled:opacity-50 disabled:cursor-not-allowed ${error ? 'border-red-500' : ''} ${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -1,15 +0,0 @@
|
||||
// Label.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Label: React.FC<LabelProps> = ({ children, required, className = '', ...props }) => {
|
||||
return (
|
||||
<label className={`block text-sm font-medium text-white/80 mb-2 ${className}`} {...props}>
|
||||
{children}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'sr', name: 'Srpski' },
|
||||
];
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === i18n.language)?.name || 'Language';
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
<button
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white
|
||||
px-4 py-2 rounded-full transition-all duration-200
|
||||
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
||||
hover:scale-105 active:scale-95"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<Globe className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm">{currentLanguage}</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown with CSS transitions */}
|
||||
<div
|
||||
className={`absolute ${isMobile ? 'bottom-full mb-2' : 'top-full mt-2'} right-0 w-48 rounded-lg overflow-hidden
|
||||
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm shadow-lg
|
||||
transition-all duration-200 origin-top
|
||||
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
||||
role="listbox"
|
||||
aria-label="Languages"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="py-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||
hover:bg-zinc-800/50
|
||||
${i18n.language === lang.code
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
i18n.changeLanguage(lang.code);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={i18n.language === lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -1,274 +0,0 @@
|
||||
// Layout.tsx
|
||||
import { useEffect, useState, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
User,
|
||||
Briefcase,
|
||||
BookOpen,
|
||||
Phone,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
// Supabase wird lazy geladen um Initial Bundle zu reduzieren
|
||||
import { ContactInfo } from "./ContactInfo";
|
||||
import { NavLink } from "./NavLink";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import Footer from "./Footer";
|
||||
import { useScrollContext } from "./ScrollContext";
|
||||
import { PerformanceMonitor } from "./PerformanceMonitor";
|
||||
import GlobalBackground from "./GlobalBackground";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const location = useLocation();
|
||||
const { isMenuOpen, setIsMenuOpen } = useScrollContext();
|
||||
|
||||
// Initial mount with immediate execution
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to ensure smooth initial render
|
||||
requestAnimationFrame(() => {
|
||||
setIsMounted(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Schließe das Menü beim Routenwechsel
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [location.pathname, setIsMenuOpen]);
|
||||
|
||||
// Toggle Menu
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
|
||||
// Scroll-Handler
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 0);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Prüfe Admin-Status verzögert und lazy (nach Initial Render)
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
// Dynamischer Import von Supabase - wird erst geladen wenn benötigt
|
||||
const { supabase } = await import("../utils/supabaseClient");
|
||||
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) {
|
||||
console.error("Error checking admin status:", error);
|
||||
return;
|
||||
}
|
||||
if (data.session) {
|
||||
const { user } = data.session;
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from("profiles")
|
||||
.select("isAdmin")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
if (adminError) {
|
||||
console.error("Error checking admin role:", adminError);
|
||||
return;
|
||||
}
|
||||
setIsAdmin(adminData.isAdmin);
|
||||
}
|
||||
};
|
||||
|
||||
// Verzögere Admin-Check deutlich (5s) um kritischen Pfad nicht zu blockieren
|
||||
const timeoutId = setTimeout(checkAdmin, 5000);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
path: "/",
|
||||
label: t("navigation.main.home"),
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
label: t("navigation.main.about"),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/portfolio",
|
||||
label: t("navigation.main.portfolio"),
|
||||
icon: <Briefcase className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/blog",
|
||||
label: t("navigation.main.blog"),
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/contact",
|
||||
label: t("navigation.main.contact"),
|
||||
icon: <Phone className="h-5 w-5" />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
path: "/dashboard",
|
||||
label: t("navigation.admin.dashboard"),
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Render with opacity 0 on initial mount to prevent flicker
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent">
|
||||
<GlobalBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col relative">
|
||||
{/* Global Background */}
|
||||
<GlobalBackground />
|
||||
|
||||
{/* Navigation – oberste Ebene */}
|
||||
<nav
|
||||
className={`
|
||||
fixed w-full z-40 transition-all duration-300
|
||||
bg-zinc-800/50 backdrop-blur-sm border-b border-zinc-700/30
|
||||
${scrolled
|
||||
? "bg-zinc-800/70 backdrop-blur-md shadow-lg shadow-zinc-900/30"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2 group shrink-0">
|
||||
<img
|
||||
src="/header-logo.svg"
|
||||
alt="Damjan Savić Logo"
|
||||
className="h-8 w-auto transition-transform duration-200 hover:scale-105"
|
||||
width={120}
|
||||
height={32}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
className="text-sm"
|
||||
/>
|
||||
))}
|
||||
<div className="ml-4 text-zinc-400 pl-2 border-l border-zinc-700">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden relative z-50 p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
onClick={toggleMenu}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu - CSS-only Animationen */}
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 md:hidden z-20 pointer-events-none transition-opacity duration-200 ${
|
||||
isMenuOpen ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed right-0 top-0 h-full w-80 bg-zinc-900/95 backdrop-blur-md shadow-xl md:hidden z-50 border-l border-zinc-800 transition-transform duration-300 ease-out ${
|
||||
isMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
{/* Sidebar Header mit Close-Button */}
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-800">
|
||||
<span className="text-white font-semibold">Menu</span>
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollbarer Sidebar-Inhalt */}
|
||||
<div className="h-[calc(100vh_-_4rem)] overflow-y-auto">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Contact Info */}
|
||||
<ContactInfo />
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="py-4 px-4">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="flex items-center w-full mb-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="px-4 py-3 border-t border-zinc-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Sprache ändern
|
||||
</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<main className="flex-1 pt-2 relative z-10 bg-transparent">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Performance Monitor */}
|
||||
<PerformanceMonitor />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface LazyComponentProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LazyComponent: React.FC<LazyComponentProps> = ({ children, fallback = <LoadingSpinner /> }) => {
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyComponent;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner = ({ size = 'md', className = '' }: LoadingSpinnerProps) => {
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
className={`relative ${sizes[size]}`}
|
||||
>
|
||||
{/* Base Triangle */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M50 10 L90 80 L10 80 Z"
|
||||
className="fill-none stroke-zinc-800"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Animated Triangle Segments */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<motion.path
|
||||
d="M50 10 L90 80"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M90 80 L10 80"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: 0.66
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M10 80 L50 10"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: 1.33
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Optional inner gradient effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900/50 to-transparent rounded-full" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -1,28 +0,0 @@
|
||||
// src/components/MDXProvider.tsx
|
||||
import React from 'react';
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
|
||||
const components = {
|
||||
h1: (props: React.ComponentProps<'h1'>) => (
|
||||
<h1 className="text-3xl font-bold my-6" {...props} />
|
||||
),
|
||||
h2: (props: React.ComponentProps<'h2'>) => (
|
||||
<h2 className="text-2xl font-bold my-4" {...props} />
|
||||
),
|
||||
h3: (props: React.ComponentProps<'h3'>) => (
|
||||
<h3 className="text-xl font-bold my-3" {...props} />
|
||||
),
|
||||
p: (props: React.ComponentProps<'p'>) => (
|
||||
<p className="my-4 text-muted-foreground" {...props} />
|
||||
),
|
||||
code: (props: React.ComponentProps<'code'>) => (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props} />
|
||||
),
|
||||
pre: (props: React.ComponentProps<'pre'>) => (
|
||||
<pre className="bg-muted p-4 rounded-lg my-4 overflow-x-auto" {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
export function MDXLayout({ children }: { children: React.ReactNode }) {
|
||||
return <MDXProvider components={components}>{children}</MDXProvider>;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// components/NavLink.tsx
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
|
||||
interface NavLinkProps {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavLink({ to, icon, label, onClick, className }: NavLinkProps) {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname === to
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||
transition-all duration-200 ease-in-out
|
||||
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-sm tracking-wide">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// components/PageContainer.tsx
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageContainer = ({ children }: PageContainerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Suspense fallback={null}>
|
||||
<main className="flex-1 pt-16">
|
||||
{children}
|
||||
</main>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer;
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Simplified page transition - no loading delay for better LCP
|
||||
const PageTransition = ({ children }: PageTransitionProps) => {
|
||||
const location = useLocation()
|
||||
const previousPathRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only scroll on path change, not on initial mount
|
||||
if (previousPathRef.current && location.pathname !== previousPathRef.current) {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
previousPathRef.current = location.pathname
|
||||
}, [location.pathname])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default PageTransition
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Activity, TrendingUp, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface WebVitalMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: 'good' | 'needs-improvement' | 'poor';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
lcp: WebVitalMetric[];
|
||||
inp: WebVitalMetric[];
|
||||
cls: WebVitalMetric[];
|
||||
fcp: WebVitalMetric[];
|
||||
ttfb: WebVitalMetric[];
|
||||
}
|
||||
|
||||
const THRESHOLDS = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
FCP: { good: 1800, poor: 3000 },
|
||||
TTFB: { good: 800, poor: 1800 }
|
||||
};
|
||||
|
||||
export function PerformanceMonitor() {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
lcp: [],
|
||||
inp: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: []
|
||||
});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for performance metrics from webVitals
|
||||
const handlePerformanceData = (event: CustomEvent) => {
|
||||
const metric = event.detail as WebVitalMetric;
|
||||
setMetrics(prev => ({
|
||||
...prev,
|
||||
[metric.name.toLowerCase()]: [...(prev[metric.name.toLowerCase() as keyof PerformanceMetrics] || []), metric].slice(-10)
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('web-vitals', handlePerformanceData as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('web-vitals', handlePerformanceData as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getLatestMetric = (metricName: keyof PerformanceMetrics) => {
|
||||
const metricArray = metrics[metricName];
|
||||
return metricArray[metricArray.length - 1];
|
||||
};
|
||||
|
||||
const getRatingColor = (rating?: string) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-500';
|
||||
case 'needs-improvement': return 'text-yellow-500';
|
||||
case 'poor': return 'text-red-500';
|
||||
default: return 'text-zinc-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating?: string) => {
|
||||
switch (rating) {
|
||||
case 'good': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'needs-improvement': return <AlertCircle className="h-4 w-4" />;
|
||||
case 'poor': return <AlertCircle className="h-4 w-4" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (name: string, value?: number) => {
|
||||
if (value === undefined) return '-';
|
||||
if (name === 'CLS') return value.toFixed(3);
|
||||
return `${Math.round(value)}ms`;
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const latestLCP = getLatestMetric('lcp');
|
||||
const latestINP = getLatestMetric('inp');
|
||||
const latestCLS = getLatestMetric('cls');
|
||||
|
||||
if (!latestLCP || !latestINP || !latestCLS) return null;
|
||||
|
||||
let score = 100;
|
||||
|
||||
// LCP scoring
|
||||
if (latestLCP.value > THRESHOLDS.LCP.poor) score -= 33;
|
||||
else if (latestLCP.value > THRESHOLDS.LCP.good) score -= 16;
|
||||
|
||||
// INP scoring
|
||||
if (latestINP.value > THRESHOLDS.INP.poor) score -= 33;
|
||||
else if (latestINP.value > THRESHOLDS.INP.good) score -= 16;
|
||||
|
||||
// CLS scoring
|
||||
if (latestCLS.value > THRESHOLDS.CLS.poor) score -= 34;
|
||||
else if (latestCLS.value > THRESHOLDS.CLS.good) score -= 18;
|
||||
|
||||
return Math.max(0, Math.round(score));
|
||||
};
|
||||
|
||||
// Only show in development or with special flag
|
||||
if (process.env.NODE_ENV === 'production' && !window.location.search.includes('debug=true')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="fixed bottom-4 right-4 z-50 p-3 bg-zinc-800 border border-zinc-700 rounded-full shadow-lg hover:bg-zinc-700 transition-all"
|
||||
aria-label="Toggle Performance Monitor"
|
||||
>
|
||||
<Activity className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Performance Panel */}
|
||||
{isVisible && (
|
||||
<div className="fixed bottom-20 right-4 z-50 w-80 bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Core Web Vitals
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overall Score */}
|
||||
{calculateScore() !== null && (
|
||||
<div className="mb-4 p-3 bg-zinc-800 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">Performance Score</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
calculateScore()! >= 90 ? 'text-green-500' :
|
||||
calculateScore()! >= 50 ? 'text-yellow-500' :
|
||||
'text-red-500'
|
||||
}`}>
|
||||
{calculateScore()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'lcp', label: 'Largest Contentful Paint', threshold: THRESHOLDS.LCP },
|
||||
{ key: 'inp', label: 'Interaction to Next Paint', threshold: THRESHOLDS.INP },
|
||||
{ key: 'cls', label: 'Cumulative Layout Shift', threshold: THRESHOLDS.CLS },
|
||||
{ key: 'fcp', label: 'First Contentful Paint', threshold: THRESHOLDS.FCP },
|
||||
{ key: 'ttfb', label: 'Time to First Byte', threshold: THRESHOLDS.TTFB }
|
||||
].map(({ key, label }) => {
|
||||
const latest = getLatestMetric(key as keyof PerformanceMetrics);
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between p-2 bg-zinc-800/50 rounded">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-300">{label}</div>
|
||||
<div className="text-xs text-zinc-500 uppercase">{key}</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${getRatingColor(latest?.rating)}`}>
|
||||
{getRatingIcon(latest?.rating)}
|
||||
<span className="font-mono text-sm">
|
||||
{formatValue(key.toUpperCase(), latest?.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Real user metrics • Updates live •
|
||||
<a
|
||||
href="https://web.dev/vitals/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface NavigationItem {
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PostNavigationProps {
|
||||
previous?: NavigationItem;
|
||||
next?: NavigationItem;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
const PostNavigation: React.FC<PostNavigationProps> = ({ previous, next, basePath }) => {
|
||||
if (!previous && !next) return null;
|
||||
|
||||
return (
|
||||
<nav className="border-t border-white/10 mt-12 pt-8">
|
||||
<div className="flex justify-between">
|
||||
{previous ? (
|
||||
<Link
|
||||
to={`${basePath}/${previous.slug}`}
|
||||
className="group flex items-center text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2 transition-transform group-hover:-translate-x-1" />
|
||||
<div>
|
||||
<div className="text-sm text-white/40">Previous</div>
|
||||
<div className="line-clamp-1">{previous.title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{next ? (
|
||||
<Link
|
||||
to={`${basePath}/${next.slug}`}
|
||||
className="group flex items-center text-right text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-white/40">Next</div>
|
||||
<div className="line-clamp-1">{next.title}</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostNavigation
|
||||
@@ -1,171 +0,0 @@
|
||||
// components/ProjectContentTemplate.tsx
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Building2, Clock, Tag } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
points?: string[];
|
||||
code?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface ProjectTranslation {
|
||||
meta: {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
client: string;
|
||||
duration: string;
|
||||
technologies: string[];
|
||||
};
|
||||
content: {
|
||||
intro: string;
|
||||
challenge: Section;
|
||||
solution: Section;
|
||||
technical: Section;
|
||||
implementation: Section;
|
||||
results: Section;
|
||||
conclusion: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectContentTemplateProps {
|
||||
translationKey: string;
|
||||
fallbackData?: ProjectTranslation;
|
||||
}
|
||||
|
||||
const ProjectContentTemplate: React.FC<ProjectContentTemplateProps> = ({
|
||||
translationKey,
|
||||
fallbackData
|
||||
}) => {
|
||||
// Nur 't' aus dem Hook extrahieren, da 'i18n' nicht verwendet wird
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Übersetzten Inhalt abrufen
|
||||
const translatedContent = t(`${translationKey}`, {
|
||||
returnObjects: true,
|
||||
defaultValue: fallbackData
|
||||
}) as ProjectTranslation;
|
||||
|
||||
const renderSection = (section: Section) => {
|
||||
if (!section) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.description && (
|
||||
<p className="text-zinc-400 mb-6">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
{section.content && (
|
||||
<div className="prose prose-invert max-w-none mb-6">
|
||||
{section.content}
|
||||
</div>
|
||||
)}
|
||||
{section.points && (
|
||||
<ul className="list-disc list-inside text-zinc-300 space-y-2">
|
||||
{section.points.map((point, index) => (
|
||||
<li key={index} className="ml-4">
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{section.code && (
|
||||
<pre className="bg-zinc-800/50 p-4 rounded-lg overflow-x-auto border border-zinc-800">
|
||||
<code className="text-zinc-300">
|
||||
{section.code}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
{section.image && (
|
||||
<div className="mt-6">
|
||||
<img
|
||||
src={section.image}
|
||||
alt={section.title}
|
||||
className="rounded-lg w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="max-w-4xl mx-auto">
|
||||
{/* Project Meta Information */}
|
||||
<div className="mb-12">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6 text-zinc-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<time>{translatedContent.meta.date}</time>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{translatedContent.meta.client}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{translatedContent.meta.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||
{translatedContent.meta.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-zinc-400">
|
||||
{translatedContent.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Introduction */}
|
||||
<div className="prose prose-invert max-w-none mb-12">
|
||||
<p>{translatedContent.content.intro}</p>
|
||||
</div>
|
||||
|
||||
{/* Main Sections */}
|
||||
{renderSection(translatedContent.content.challenge)}
|
||||
{renderSection(translatedContent.content.solution)}
|
||||
{renderSection(translatedContent.content.technical)}
|
||||
{renderSection(translatedContent.content.implementation)}
|
||||
{renderSection(translatedContent.content.results)}
|
||||
|
||||
{/* Conclusion */}
|
||||
{translatedContent.content.conclusion && (
|
||||
<div className="prose prose-invert max-w-none mb-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
{t('portfolio.sections.conclusion')}
|
||||
</h2>
|
||||
<p>{translatedContent.content.conclusion}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{translatedContent.meta.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
|
||||
>
|
||||
{t(`technologies.${tech}`, tech)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectContentTemplate;
|
||||
@@ -1,161 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Github, Globe, Zap, TrendingUp, Users } from 'lucide-react';
|
||||
import { ProjectSchema } from './schemas/ProjectSchema';
|
||||
|
||||
interface ProjectShowcaseProps {
|
||||
project: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
technicalChallenge: string;
|
||||
businessImpact: {
|
||||
roi?: string;
|
||||
performanceGain?: string;
|
||||
usersImpacted?: string;
|
||||
};
|
||||
technologies: string[];
|
||||
category: string;
|
||||
dateCreated: string;
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
screenshotUrl?: string;
|
||||
features: string[];
|
||||
codeExample?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProjectShowcase({ project }: ProjectShowcaseProps) {
|
||||
return (
|
||||
<article className="project-showcase bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ProjectSchema project={project} />
|
||||
|
||||
{/* Hero Section with Screenshot */}
|
||||
{project.screenshotUrl && (
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={project.screenshotUrl}
|
||||
alt={`Screenshot of ${project.title}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Title and Description */}
|
||||
<header>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">{project.title}</h2>
|
||||
<p className="text-lg text-zinc-300 leading-relaxed">{project.description}</p>
|
||||
</header>
|
||||
|
||||
{/* For Recruiters: Technical Challenge */}
|
||||
<section className="technical-challenge">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="h-5 w-5 text-blue-500" />
|
||||
<h3 className="text-xl font-semibold text-white">Technical Challenge</h3>
|
||||
</div>
|
||||
<p className="text-zinc-300 bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
{project.technicalChallenge}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* For Clients: Business Impact */}
|
||||
<section className="business-impact">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
<h3 className="text-xl font-semibold text-white">Business Impact</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{project.businessImpact.roi && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">ROI</p>
|
||||
<p className="text-2xl font-bold text-green-400">{project.businessImpact.roi}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.businessImpact.performanceGain && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">Performance</p>
|
||||
<p className="text-2xl font-bold text-blue-400">{project.businessImpact.performanceGain}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.businessImpact.usersImpacted && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">Users Impacted</p>
|
||||
<p className="text-2xl font-bold text-purple-400">{project.businessImpact.usersImpacted}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technology Stack with Keywords */}
|
||||
<section className="tech-stack">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Technologies Used</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.technologies.map(tech => (
|
||||
<span
|
||||
key={tech}
|
||||
className="tech-tag px-3 py-1 bg-zinc-800 border border-zinc-700 text-zinc-300 rounded-full text-sm hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Features */}
|
||||
{project.features && project.features.length > 0 && (
|
||||
<section className="features">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Key Features</h3>
|
||||
<ul className="space-y-2">
|
||||
{project.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-0.5">•</span>
|
||||
<span className="text-zinc-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Code Quality Showcase */}
|
||||
{project.codeExample && (
|
||||
<section className="code-showcase">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Code Example</h3>
|
||||
<pre className="bg-zinc-950 p-4 rounded-lg overflow-x-auto border border-zinc-800">
|
||||
<code className="text-sm text-zinc-300 font-mono">
|
||||
{project.codeExample}
|
||||
</code>
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="project-links flex gap-4 pt-4">
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
View Live Project
|
||||
</a>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
interface ResponsiveImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
|
||||
export default function ResponsiveImage({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
sizes = '100vw',
|
||||
loading = 'lazy',
|
||||
fetchpriority = 'auto'
|
||||
}: ResponsiveImageProps) {
|
||||
// Generate Unsplash responsive URLs
|
||||
const generateSrcSet = (url: string) => {
|
||||
if (!url.includes('unsplash.com')) return undefined;
|
||||
|
||||
const widths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
|
||||
return widths
|
||||
.map((w) => {
|
||||
const modifiedUrl = url.replace(/w=\d+/, `w=${w}`);
|
||||
return `${modifiedUrl} ${w}w`;
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const srcSet = generateSrcSet(src);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={loading}
|
||||
fetchpriority={fetchpriority}
|
||||
sizes={sizes}
|
||||
srcSet={srcSet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Home, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
const error = useRouteError();
|
||||
const navigate = useNavigate();
|
||||
|
||||
let title = 'Something went wrong';
|
||||
let message = 'An unexpected error occurred';
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 404) {
|
||||
title = 'Page not found';
|
||||
message = "The page you're looking for doesn't exist or has been moved.";
|
||||
} else {
|
||||
title = `Error ${error.status}`;
|
||||
message = error.statusText || 'An error occurred while processing your request.';
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
message = error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-black">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-4">{title}</h1>
|
||||
<p className="text-white/70 mb-8">{message}</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Home Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteErrorBoundary;
|
||||
@@ -1,183 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HreflangTags } from './HreflangTags';
|
||||
import { seoContent as seoContentDe } from '../i18n/locales/de/seo-content';
|
||||
import { seoContent as seoContentEn } from '../i18n/locales/en/seo-content';
|
||||
import { seoContent as seoContentSr } from '../i18n/locales/sr/seo-content';
|
||||
import { meta as metaDe } from '../i18n/locales/de/meta';
|
||||
import { meta as metaEn } from '../i18n/locales/en/meta';
|
||||
import { meta as metaSr } from '../i18n/locales/sr/meta';
|
||||
import { AutoBreadcrumbs } from './schemas/BreadcrumbSchema';
|
||||
|
||||
interface SEOProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
article?: boolean;
|
||||
schema?: object;
|
||||
keywords?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
const SEO: React.FC<SEOProps> = ({
|
||||
title,
|
||||
description,
|
||||
image = '/portrait.jpg',
|
||||
article = false,
|
||||
schema,
|
||||
keywords,
|
||||
author = 'Damjan Savić',
|
||||
}) => {
|
||||
// Nur i18n wird benötigt – t wurde entfernt, da es nicht genutzt wird.
|
||||
const { i18n } = useTranslation();
|
||||
const siteTitle = 'Damjan Savić';
|
||||
const fullTitle = title ? `${title} | ${siteTitle}` : siteTitle;
|
||||
const siteUrl = 'https://damjan-savic.com';
|
||||
const currentUrl = typeof window !== 'undefined' ? window.location.href : siteUrl;
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
// SEO-Content basierend auf Sprache auswählen
|
||||
const seoContent = currentLanguage === 'de' ? seoContentDe :
|
||||
currentLanguage === 'sr' ? seoContentSr :
|
||||
seoContentEn;
|
||||
const meta = currentLanguage === 'de' ? metaDe :
|
||||
currentLanguage === 'sr' ? metaSr :
|
||||
metaEn;
|
||||
const defaultDescription = description || seoContent.hero.description;
|
||||
const defaultKeywords = keywords || meta.site.keywords;
|
||||
|
||||
// Default schema für die Website
|
||||
const defaultSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: 'Damjan Savić',
|
||||
alternateName: 'Damjan Savić',
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}${image}`,
|
||||
description: defaultDescription,
|
||||
sameAs: [
|
||||
'https://linkedin.com/in/damjansavic',
|
||||
'https://github.com/damjansavic'
|
||||
],
|
||||
jobTitle: currentLanguage === 'de' ?
|
||||
['Senior Fullstack Entwickler', 'Digital Solutions Consultant', 'Software Architekt', 'KI/AI Spezialist'] :
|
||||
currentLanguage === 'sr' ?
|
||||
['Старији програмер пуног стека', 'Консултант за дигитална решења', 'Софтверски архитекта'] :
|
||||
['Senior Fullstack Developer', 'Digital Solutions Consultant', 'Software Architect', 'AI Specialist'],
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'CoderConda'
|
||||
},
|
||||
knowsAbout: [
|
||||
'Python Development',
|
||||
'JavaScript Development',
|
||||
'React.js',
|
||||
'Next.js',
|
||||
'TypeScript',
|
||||
'Electron Desktop Applications',
|
||||
'Künstliche Intelligenz (KI/AI)',
|
||||
'OLLAMA AI/ML',
|
||||
'ERP Systems Integration',
|
||||
'E-Commerce Development',
|
||||
'Process Automation',
|
||||
'Backend Development',
|
||||
'Frontend Development',
|
||||
'Full Stack Development'
|
||||
],
|
||||
hasSkill: [
|
||||
{
|
||||
'@type': 'DefinedTerm',
|
||||
name: 'Python Development',
|
||||
description: 'Expert-level Python programming for automation, backend development, and AI/ML applications'
|
||||
},
|
||||
{
|
||||
'@type': 'DefinedTerm',
|
||||
name: 'JavaScript/TypeScript Development',
|
||||
description: 'Full-stack JavaScript development with React, Next.js, Node.js, and TypeScript'
|
||||
},
|
||||
{
|
||||
'@type': 'DefinedTerm',
|
||||
name: 'AI/ML with OLLAMA',
|
||||
description: 'Implementation of AI solutions using OLLAMA for local language models'
|
||||
},
|
||||
{
|
||||
'@type': 'DefinedTerm',
|
||||
name: 'ERP & E-Commerce Integration',
|
||||
description: 'Custom ERP system development and e-commerce platform integration'
|
||||
}
|
||||
],
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressCountry: 'DE',
|
||||
addressLocality: 'Köln'
|
||||
}
|
||||
};
|
||||
|
||||
// Sicherstellen, dass supportedLngs ein Array ist, bevor .filter verwendet wird
|
||||
const alternateUrls: Array<{ hrefLang: string; href: string }> = Array.isArray(i18n.options.supportedLngs)
|
||||
? i18n.options.supportedLngs
|
||||
.filter((lng: string) => lng !== 'cimode')
|
||||
.map((lng: string) => ({
|
||||
hrefLang: lng === 'de' ? 'de-DE' : lng === 'sr' ? 'sr-RS' : 'en-US',
|
||||
href: lng === 'de' ?
|
||||
`${siteUrl}${window.location.pathname}` :
|
||||
`${siteUrl}/${lng}${window.location.pathname}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<HreflangTags />
|
||||
<AutoBreadcrumbs />
|
||||
<Helmet>
|
||||
{/* Basic meta tags */}
|
||||
<html lang={currentLanguage} />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={defaultDescription} />
|
||||
<meta name="image" content={image} />
|
||||
<link rel="canonical" href={currentUrl} />
|
||||
|
||||
{/* Open Graph meta tags */}
|
||||
<meta property="og:url" content={currentUrl} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={defaultDescription} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:type" content={article ? 'article' : 'website'} />
|
||||
<meta property="og:site_name" content={siteTitle} />
|
||||
<meta property="og:locale" content={
|
||||
currentLanguage === 'de' ? 'de_DE' :
|
||||
currentLanguage === 'sr' ? 'sr_RS' :
|
||||
'en_US'
|
||||
} />
|
||||
{alternateUrls?.map(
|
||||
({ hrefLang }: { hrefLang: string; href: string }) => (
|
||||
<meta key={hrefLang} property="og:locale:alternate" content={hrefLang} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Twitter Card meta tags */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={defaultDescription} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
|
||||
{/* Additional meta tags */}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content={defaultKeywords}
|
||||
/>
|
||||
<meta name="author" content={author} />
|
||||
|
||||
{/* Schema.org markup */}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(schema || defaultSchema)}
|
||||
</script>
|
||||
</Helmet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEO;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { motion, useAnimation } from "framer-motion"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
interface ScrollAnimationProps {
|
||||
children: React.ReactNode
|
||||
delay?: number
|
||||
}
|
||||
|
||||
const ScrollAnimation: React.FC<ScrollAnimationProps> = ({ children, delay = 0 }) => {
|
||||
const controls = useAnimation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
controls.start({ opacity: 1, y: 0, transition: { duration: 0.5, delay: delay } })
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current)
|
||||
}
|
||||
}
|
||||
}, [controls, delay])
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} initial={{ opacity: 0, y: 20 }} animate={controls}>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollAnimation
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// ScrollContext.tsx
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface ScrollContextType {
|
||||
isTransitioning: boolean;
|
||||
setIsTransitioning: (value: boolean) => void;
|
||||
isMenuOpen: boolean;
|
||||
setIsMenuOpen: (value: boolean) => void;
|
||||
lockScroll: () => void;
|
||||
unlockScroll: () => void;
|
||||
}
|
||||
|
||||
const ScrollContext = createContext<ScrollContextType | undefined>(undefined);
|
||||
|
||||
export const ScrollProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const lockScroll = useCallback(() => {
|
||||
const scrollY = window.scrollY;
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${scrollY}px`;
|
||||
document.body.style.width = '100%';
|
||||
}, []);
|
||||
|
||||
const unlockScroll = useCallback(() => {
|
||||
const scrollY = document.body.style.top;
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.style.width = '';
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider
|
||||
value={{
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
lockScroll,
|
||||
unlockScroll
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrollContext = () => {
|
||||
const context = useContext(ScrollContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useScrollContext must be used within a ScrollProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Share2, X, Twitter, Facebook, Linkedin, Link as LinkIcon } from 'lucide-react';
|
||||
import { useOnClickOutside } from '../hooks/useOnClickOutside';
|
||||
|
||||
interface ShareMenuProps {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ShareMenu: React.FC<ShareMenuProps> = ({ title, url, description }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOnClickOutside(menuRef, () => setIsOpen(false));
|
||||
|
||||
const shareLinks = [
|
||||
{
|
||||
name: 'Twitter',
|
||||
icon: Twitter,
|
||||
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
|
||||
label: 'Share on Twitter'
|
||||
},
|
||||
{
|
||||
name: 'Facebook',
|
||||
icon: Facebook,
|
||||
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
label: 'Share on Facebook'
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn',
|
||||
icon: Linkedin,
|
||||
href: `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&summary=${encodeURIComponent(description)}`,
|
||||
label: 'Share on LinkedIn'
|
||||
},
|
||||
];
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopyStatus('copied');
|
||||
setTimeout(() => setCopyStatus('idle'), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-black transition-colors p-2 rounded-full hover:bg-white/10"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Share this content"
|
||||
>
|
||||
{isOpen ? <X className="h-5 w-5" aria-hidden="true" /> : <Share2 className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-48 rounded-lg bg-gray-900 shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="py-1">
|
||||
{shareLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-orange-500"
|
||||
role="menuitem"
|
||||
aria-label={link.label}
|
||||
>
|
||||
<link.icon className="h-4 w-4 mr-3" aria-hidden="true" />
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-orange-500"
|
||||
role="menuitem"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4 mr-3" aria-hidden="true" />
|
||||
{copyStatus === 'copied' ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareMenu
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Skeleton: React.FC<SkeletonProps> = ({ className }) => (
|
||||
<div className={`animate-pulse bg-gray-700/50 rounded ${className}`}></div>
|
||||
);
|
||||
|
||||
export const BlogPostSkeleton = () => (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<Skeleton className="h-8 w-3/4 mb-4" />
|
||||
<Skeleton className="h-4 w-1/4 mb-8" />
|
||||
<Skeleton className="h-[400px] w-full mb-8 rounded-xl" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProjectSkeleton = () => (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<Skeleton className="h-8 w-2/3 mb-4" />
|
||||
<div className="flex gap-4 mb-8">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full mb-8 rounded-xl" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PortfolioSkeleton = () => (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<Skeleton className="h-8 w-48 mx-auto mb-12" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="aspect-[4/3]">
|
||||
<Skeleton className="w-full h-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
interface Skill {
|
||||
name: string;
|
||||
level: number;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SkillsMatrixProps {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
const SkillsMatrix: React.FC<SkillsMatrixProps> = ({ skills }) => {
|
||||
const [ref, inView] = useInView({
|
||||
triggerOnce: true,
|
||||
threshold: 0.1,
|
||||
});
|
||||
|
||||
const categories = Array.from(new Set(skills.map(skill => skill.category)));
|
||||
|
||||
return (
|
||||
<div ref={ref} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<div key={category} className="bg-gray-900/50 p-6 rounded-xl">
|
||||
<h3 className="text-xl font-semibold mb-6">{category}</h3>
|
||||
<div className="space-y-6">
|
||||
{skills
|
||||
.filter(skill => skill.category === category)
|
||||
.map((skill, skillIndex) => (
|
||||
<div key={skill.name}>
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="group relative">
|
||||
<span>{skill.name}</span>
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 p-4 bg-gray-800 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<p className="text-sm text-white/80">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span>{skill.level}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-orange-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={inView ? { width: `${skill.level}%` } : { width: 0 }}
|
||||
transition={{ duration: 1, delay: categoryIndex * 0.2 + skillIndex * 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillsMatrix;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const SkipToContent: React.FC = () => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className={`
|
||||
fixed top-4 left-4 px-4 py-2 bg-orange-500 text-white rounded-lg
|
||||
transform transition-transform duration-200
|
||||
${isFocused ? 'translate-y-0' : '-translate-y-full'}
|
||||
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2
|
||||
`}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkipToContent
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Star, Quote } from 'lucide-react';
|
||||
import ImageWithFallback from './ImageWithFallback';
|
||||
|
||||
interface TestimonialProps {
|
||||
name: string;
|
||||
role: string;
|
||||
company: string;
|
||||
image: string;
|
||||
content: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
const TestimonialCard: React.FC<TestimonialProps> = ({
|
||||
name,
|
||||
role,
|
||||
company,
|
||||
image,
|
||||
content,
|
||||
rating
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gray-900/50 p-6 rounded-xl relative">
|
||||
<Quote className="absolute top-4 right-4 h-8 w-8 text-orange-500/20" />
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<ImageWithFallback
|
||||
src={image}
|
||||
alt={name}
|
||||
className="w-12 h-12 rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold">{name}</h3>
|
||||
<p className="text-sm text-white/60">{role} at {company}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${i < rating ? 'text-orange-500' : 'text-gray-600'}`}
|
||||
fill={i < rating ? 'currentColor' : 'none'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-white/80">{content}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialCard;
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className = '', error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
className={`w-full bg-zinc-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-primary transition-all resize-none
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : ''}
|
||||
${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export default Textarea;
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbSchemaProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function BreadcrumbSchema({ items }: BreadcrumbSchemaProps) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": items.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
"position": index + 1,
|
||||
"name": item.name,
|
||||
"item": item.url
|
||||
}))
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook to generate breadcrumb items based on current route
|
||||
export function useBreadcrumbs(pathname: string) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const siteUrl = 'https://damjan-savic.com';
|
||||
const currentLang = i18n.language;
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
// Always add home
|
||||
breadcrumbs.push({
|
||||
name: t('navigation.home', 'Home'),
|
||||
url: currentLang === 'de' ? siteUrl : `${siteUrl}/${currentLang}`
|
||||
});
|
||||
|
||||
// Build breadcrumbs from path segments
|
||||
let currentPath = currentLang === 'de' ? '' : `/${currentLang}`;
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
// Skip language segment
|
||||
if (index === 0 && ['en', 'de', 'sr'].includes(segment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentPath += `/${segment}`;
|
||||
|
||||
// Map segment to readable name
|
||||
let name = segment;
|
||||
switch (segment) {
|
||||
case 'portfolio':
|
||||
name = t('navigation.portfolio', 'Portfolio');
|
||||
break;
|
||||
case 'blog':
|
||||
name = t('navigation.blog', 'Blog');
|
||||
break;
|
||||
case 'about':
|
||||
name = t('navigation.about', 'About');
|
||||
break;
|
||||
case 'contact':
|
||||
name = t('navigation.contact', 'Contact');
|
||||
break;
|
||||
default:
|
||||
// For dynamic segments like project names, format them nicely
|
||||
name = segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
breadcrumbs.push({
|
||||
name,
|
||||
url: `${siteUrl}${currentPath}`
|
||||
});
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Component with automatic breadcrumb generation
|
||||
export function AutoBreadcrumbs() {
|
||||
const pathname = window.location.pathname;
|
||||
const breadcrumbs = useBreadcrumbs(pathname);
|
||||
|
||||
// Don't show breadcrumbs on home page
|
||||
if (breadcrumbs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BreadcrumbSchema items={breadcrumbs} />;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HowToStep {
|
||||
name: string;
|
||||
text: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface HowToSchemaProps {
|
||||
title: string;
|
||||
description: string;
|
||||
totalTime?: string;
|
||||
estimatedCost?: {
|
||||
value: string;
|
||||
currency: string;
|
||||
};
|
||||
supply?: string[];
|
||||
tool?: string[];
|
||||
steps: HowToStep[];
|
||||
image?: string;
|
||||
video?: {
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
uploadDate: string;
|
||||
duration: string;
|
||||
embedUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function HowToSchema({
|
||||
title,
|
||||
description,
|
||||
totalTime,
|
||||
estimatedCost,
|
||||
supply,
|
||||
tool,
|
||||
steps,
|
||||
image,
|
||||
video
|
||||
}: HowToSchemaProps) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
"name": title,
|
||||
"description": description,
|
||||
"image": image,
|
||||
...(totalTime && { "totalTime": totalTime }),
|
||||
...(estimatedCost && {
|
||||
"estimatedCost": {
|
||||
"@type": "MonetaryAmount",
|
||||
"currency": estimatedCost.currency,
|
||||
"value": estimatedCost.value
|
||||
}
|
||||
}),
|
||||
...(supply && supply.length > 0 && {
|
||||
"supply": supply.map(item => ({
|
||||
"@type": "HowToSupply",
|
||||
"name": item
|
||||
}))
|
||||
}),
|
||||
...(tool && tool.length > 0 && {
|
||||
"tool": tool.map(item => ({
|
||||
"@type": "HowToTool",
|
||||
"name": item
|
||||
}))
|
||||
}),
|
||||
"step": steps.map((step, index) => ({
|
||||
"@type": "HowToStep",
|
||||
"name": step.name,
|
||||
"text": step.text,
|
||||
"position": index + 1,
|
||||
...(step.image && { "image": step.image }),
|
||||
...(step.url && { "url": step.url })
|
||||
})),
|
||||
...(video && {
|
||||
"video": {
|
||||
"@type": "VideoObject",
|
||||
"name": video.name,
|
||||
"description": video.description,
|
||||
"thumbnailUrl": video.thumbnailUrl,
|
||||
"uploadDate": video.uploadDate,
|
||||
"duration": video.duration,
|
||||
"embedUrl": video.embedUrl
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Example HowTo content for common queries
|
||||
export const howToExamples = {
|
||||
pythonAutomation: {
|
||||
title: "Wie kann ich Python für Prozessautomatisierung einsetzen?",
|
||||
description: "Schritt-für-Schritt Anleitung zur Automatisierung von Geschäftsprozessen mit Python",
|
||||
totalTime: "PT30M",
|
||||
tool: ["Python 3.8+", "pip", "Virtual Environment", "Code Editor (VS Code)"],
|
||||
steps: [
|
||||
{
|
||||
name: "Python-Umgebung einrichten",
|
||||
text: "Installieren Sie Python 3.8 oder höher und erstellen Sie eine virtuelle Umgebung mit 'python -m venv automation-env'"
|
||||
},
|
||||
{
|
||||
name: "Notwendige Libraries installieren",
|
||||
text: "Aktivieren Sie die virtuelle Umgebung und installieren Sie benötigte Pakete: pip install selenium pandas schedule requests"
|
||||
},
|
||||
{
|
||||
name: "Automatisierungsskript erstellen",
|
||||
text: "Erstellen Sie ein Python-Skript, das Ihre spezifischen Aufgaben automatisiert. Nutzen Sie Selenium für Web-Automation, Pandas für Datenverarbeitung"
|
||||
},
|
||||
{
|
||||
name: "Zeitgesteuerte Ausführung einrichten",
|
||||
text: "Verwenden Sie die Schedule-Library oder Cron-Jobs (Linux/Mac) bzw. Task Scheduler (Windows) für regelmäßige Ausführung"
|
||||
},
|
||||
{
|
||||
name: "Monitoring und Logging implementieren",
|
||||
text: "Fügen Sie Logging hinzu, um Fehler zu tracken und den Erfolg der Automatisierung zu überwachen"
|
||||
}
|
||||
]
|
||||
},
|
||||
ollamaSetup: {
|
||||
title: "How to Set Up OLLAMA for Local AI Development",
|
||||
description: "Complete guide to installing and using OLLAMA for privacy-focused AI applications",
|
||||
totalTime: "PT45M",
|
||||
tool: ["OLLAMA CLI", "Python 3.8+", "8GB+ RAM", "Terminal/Command Prompt"],
|
||||
steps: [
|
||||
{
|
||||
name: "Install OLLAMA",
|
||||
text: "Download and install OLLAMA from ollama.ai. For Mac/Linux: curl -fsSL https://ollama.ai/install.sh | sh"
|
||||
},
|
||||
{
|
||||
name: "Download a Language Model",
|
||||
text: "Pull a model like Llama 2: ollama pull llama2. This downloads the model locally to your machine"
|
||||
},
|
||||
{
|
||||
name: "Test the Model",
|
||||
text: "Run the model interactively: ollama run llama2. Ask a test question to verify it's working"
|
||||
},
|
||||
{
|
||||
name: "Install Python Integration",
|
||||
text: "Install the Python library: pip install ollama. This allows you to use OLLAMA in your Python applications"
|
||||
},
|
||||
{
|
||||
name: "Create Your First Application",
|
||||
text: "Write Python code to interact with OLLAMA: import ollama; response = ollama.chat(model='llama2', messages=[{'role': 'user', 'content': 'Hello'}])"
|
||||
},
|
||||
{
|
||||
name: "Optimize for Production",
|
||||
text: "Configure model parameters, implement caching, and set up proper error handling for production use"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function LocalBusinessSchema() {
|
||||
const localBusinessSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": "https://damjan-savic.com/#localbusiness",
|
||||
"name": "Damjan Savić - Senior Fullstack Developer & IT Consultant",
|
||||
"alternateName": ["CoderConda", "Damjan Savić IT Services"],
|
||||
"description": "Damjan Savić bietet professionelle IT-Dienstleistungen in Köln und Umgebung. Spezialisiert auf Enterprise Software Development, KI-Integration, Cloud Architecture und digitale Transformation. Damjan Savić entwickelt maßgeschneiderte Lösungen für Ihr Unternehmen.",
|
||||
"url": "https://damjan-savic.com",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.png",
|
||||
"width": "1000",
|
||||
"height": "1000",
|
||||
"caption": "Damjan Savić - Senior Fullstack Developer & IT Consultant Logo"
|
||||
},
|
||||
"image": "https://damjan-savic.com/logo.png",
|
||||
"telephone": "+49-XXX-XXXXXXX",
|
||||
"email": "info@damjan-savic.com",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "Köln",
|
||||
"addressRegion": "Nordrhein-Westfalen",
|
||||
"postalCode": "50667",
|
||||
"addressCountry": "DE",
|
||||
"streetAddress": "Geschäftsadresse auf Anfrage"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "50.9375",
|
||||
"longitude": "6.9603"
|
||||
},
|
||||
"priceRange": "€€€",
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "09:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
],
|
||||
"areaServed": [
|
||||
{
|
||||
"@type": "City",
|
||||
"name": "Köln"
|
||||
},
|
||||
{
|
||||
"@type": "State",
|
||||
"name": "Nordrhein-Westfalen"
|
||||
},
|
||||
{
|
||||
"@type": "Country",
|
||||
"name": "Deutschland"
|
||||
},
|
||||
{
|
||||
"@type": "Continent",
|
||||
"name": "Europa"
|
||||
}
|
||||
],
|
||||
"serviceArea": {
|
||||
"@type": "GeoCircle",
|
||||
"geoMidpoint": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "50.9375",
|
||||
"longitude": "6.9603"
|
||||
},
|
||||
"geoRadius": "500 km"
|
||||
},
|
||||
"makesOffer": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Enterprise Software Development",
|
||||
"description": "Maßgeschneiderte Unternehmenssoftware von Damjan Savić"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "KI/AI Integration Services",
|
||||
"description": "Integration von KI-Lösungen mit OLLAMA und LLMs durch Damjan Savić"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Cloud Architecture Consulting",
|
||||
"description": "Cloud-native Lösungen und Migration von Damjan Savić"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Digital Transformation",
|
||||
"description": "Digitale Transformation und Prozessoptimierung mit Damjan Savić"
|
||||
}
|
||||
}
|
||||
],
|
||||
"founder": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"employee": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/damjansavic",
|
||||
"https://linkedin.com/in/damjansavic",
|
||||
"https://twitter.com/damjansavic"
|
||||
],
|
||||
"knowsLanguage": ["de-DE", "en-US", "sr-RS"],
|
||||
"paymentAccepted": ["Überweisung", "PayPal", "Rechnung"],
|
||||
"currenciesAccepted": "EUR",
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "IT-Dienstleistungen von Damjan Savić",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "Fullstack Web Development",
|
||||
"description": "Entwicklung moderner Webanwendungen mit React, Python und TypeScript"
|
||||
},
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "API Development & Integration",
|
||||
"description": "RESTful APIs, GraphQL und Microservices Architecture"
|
||||
},
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "DevOps & Cloud Infrastructure",
|
||||
"description": "CI/CD, Docker, Kubernetes und Cloud-Deployment"
|
||||
},
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "E-Commerce Solutions",
|
||||
"description": "Shopify, WooCommerce und Custom E-Commerce Plattformen"
|
||||
},
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "Business Process Automation",
|
||||
"description": "Automatisierung von Geschäftsprozessen mit Python"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessSchema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LocalizedPersonSchema() {
|
||||
const { i18n } = useTranslation();
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
const personSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"@id": "https://damjan-savic.com/#person",
|
||||
"name": currentLanguage === 'sr' ? "Дамјан Савић" : "Damjan Savić",
|
||||
"alternateName": currentLanguage === 'sr' ? ["Damjan Savić", "Damjan Savić"] : "Damjan Savić",
|
||||
"jobTitle": currentLanguage === 'de' ?
|
||||
["AI & Automation Specialist", "Process Automation Specialist", "Fullstack Entwickler"] :
|
||||
currentLanguage === 'sr' ?
|
||||
["AI & Automation Specialist", "Specijalist za automatizaciju procesa", "Fullstack Developer"] :
|
||||
["AI & Automation Specialist", "Process Automation Specialist", "Fullstack Developer"],
|
||||
"description": currentLanguage === 'de' ?
|
||||
"Damjan Savić ist AI & Automation Specialist. Spezialisiert auf KI-Agenten, Voice AI mit Vapi, Prozessautomatisierung mit n8n und Zapier. Entwickelt produktive Automatisierungslösungen und Voice-AI-Plattformen." :
|
||||
currentLanguage === 'sr' ?
|
||||
"Дамјан Савић је AI & Automation Specialist. Специјализован за AI агенте, Voice AI са Vapi, аутоматизацију процеса са n8n и Zapier. Развија продуктивна решења за аутоматизацију и Voice AI платформе." :
|
||||
"Damjan Savić is an AI & Automation Specialist. Specialized in AI agents, Voice AI with Vapi, process automation with n8n and Zapier. Building production automation solutions and Voice AI platforms.",
|
||||
"url": "https://damjan-savic.com",
|
||||
"image": [
|
||||
{
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/portrait.jpg",
|
||||
"caption": currentLanguage === 'sr' ? "Дамјан Савић портрет" : "Damjan Savić Portrait"
|
||||
},
|
||||
{
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.png",
|
||||
"caption": currentLanguage === 'sr' ? "Дамјан Савић лого" : "Damjan Savić Logo"
|
||||
}
|
||||
],
|
||||
"logo": "https://damjan-savic.com/logo.png",
|
||||
"email": "info@damjan-savic.com",
|
||||
"telephone": "+49-XXX-XXXXXXX",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"email": "info@damjan-savic.com",
|
||||
"contactType": currentLanguage === 'de' ? "Geschäftsanfragen" :
|
||||
currentLanguage === 'sr' ? "Пословни упити" :
|
||||
"Business Inquiries",
|
||||
"availableLanguage": ["de", "en", "sr"],
|
||||
"areaServed": ["DE", "EU"],
|
||||
"hoursAvailable": {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "09:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/damjansavic",
|
||||
"https://linkedin.com/in/damjansavic",
|
||||
"https://twitter.com/damjansavic",
|
||||
"https://stackoverflow.com/users/damjansavic"
|
||||
],
|
||||
"knowsAbout": currentLanguage === 'de' ? [
|
||||
"KI-Agenten Entwicklung",
|
||||
"Voice AI (Vapi)",
|
||||
"Prozessautomatisierung (n8n, Zapier)",
|
||||
"GPT-4 & Claude API Integration",
|
||||
"Python Entwicklung",
|
||||
"TypeScript & React",
|
||||
"Next.js",
|
||||
"Web Scraping",
|
||||
"WebSocket & Echtzeit-Anwendungen",
|
||||
"Supabase & PostgreSQL",
|
||||
"Backend Entwicklung",
|
||||
"Frontend Entwicklung",
|
||||
"Full Stack Entwicklung",
|
||||
"Docker",
|
||||
"Vercel Deployment"
|
||||
] : currentLanguage === 'sr' ? [
|
||||
"Развој AI агената",
|
||||
"Voice AI (Vapi)",
|
||||
"Аутоматизација процеса (n8n, Zapier)",
|
||||
"GPT-4 & Claude API интеграција",
|
||||
"Python развој",
|
||||
"TypeScript & React",
|
||||
"Next.js",
|
||||
"Web Scraping",
|
||||
"WebSocket & real-time апликације",
|
||||
"Supabase & PostgreSQL",
|
||||
"Backend развој",
|
||||
"Frontend развој",
|
||||
"Full Stack развој",
|
||||
"Docker",
|
||||
"Vercel Deployment"
|
||||
] : [
|
||||
"AI Agents Development",
|
||||
"Voice AI (Vapi)",
|
||||
"Process Automation (n8n, Zapier)",
|
||||
"GPT-4 & Claude API Integration",
|
||||
"Python Development",
|
||||
"TypeScript & React",
|
||||
"Next.js",
|
||||
"Web Scraping",
|
||||
"WebSocket & Real-time Applications",
|
||||
"Supabase & PostgreSQL",
|
||||
"Backend Development",
|
||||
"Frontend Development",
|
||||
"Full Stack Development",
|
||||
"Docker",
|
||||
"Vercel Deployment"
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": currentLanguage === 'sr' ? "Келн" : currentLanguage === 'de' ? "Köln" : "Cologne",
|
||||
"addressRegion": currentLanguage === 'sr' ? "Северна Рајна-Вестфалија" : currentLanguage === 'de' ? "Nordrhein-Westfalen" : "North Rhine-Westphalia",
|
||||
"addressCountry": currentLanguage === 'sr' ? "Немачка" : currentLanguage === 'de' ? "DE" : "Germany"
|
||||
},
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "Everlast Consulting GmbH",
|
||||
"description": currentLanguage === 'de' ?
|
||||
"Prozessautomatisierung und KI-Lösungen" :
|
||||
currentLanguage === 'sr' ?
|
||||
"Аутоматизација процеса и AI решења" :
|
||||
"Process Automation and AI Solutions",
|
||||
"url": "https://everlast-consulting.de"
|
||||
},
|
||||
"alumniOf": [
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": currentLanguage === 'sr' ? "Технички универзитет" : currentLanguage === 'de' ? "Technische Universität" : "Technical University",
|
||||
"url": "https://www.tu.edu"
|
||||
}
|
||||
],
|
||||
"award": currentLanguage === 'de' ? [
|
||||
"Zertifizierter Cloud Architekt",
|
||||
"Python Professional Zertifizierung",
|
||||
"React Expert Zertifizierung"
|
||||
] : currentLanguage === 'sr' ? [
|
||||
"Сертификовани облак архитекта",
|
||||
"Python професионална сертификација",
|
||||
"React експерт сертификација"
|
||||
] : [
|
||||
"Certified Cloud Architect",
|
||||
"Python Professional Certification",
|
||||
"React Expert Certification"
|
||||
],
|
||||
"knowsLanguage": [
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Deutsch" : currentLanguage === 'sr' ? "Немачки" : "German",
|
||||
"alternateName": "de"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Englisch" : currentLanguage === 'sr' ? "Енглески" : "English",
|
||||
"alternateName": "en"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Serbisch" : currentLanguage === 'sr' ? "Српски" : "Serbian",
|
||||
"alternateName": "sr"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Französisch" : currentLanguage === 'sr' ? "Француски" : "French",
|
||||
"alternateName": "fr"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Spanisch" : currentLanguage === 'sr' ? "Шпански" : "Spanish",
|
||||
"alternateName": "es"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": currentLanguage === 'de' ? "Russisch" : currentLanguage === 'sr' ? "Руски" : "Russian",
|
||||
"alternateName": "ru"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalizedWebsiteSchema() {
|
||||
const { i18n } = useTranslation();
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
const websiteSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": "https://damjan-savic.com/#website",
|
||||
"url": "https://damjan-savic.com",
|
||||
"name": currentLanguage === 'de' ?
|
||||
"Damjan Savić - AI & Automation Specialist" :
|
||||
currentLanguage === 'sr' ?
|
||||
"Дамјан Савић - AI & Automation Specialist" :
|
||||
"Damjan Savić - AI & Automation Specialist",
|
||||
"alternateName": ["Damjan Savić Portfolio"],
|
||||
"description": currentLanguage === 'de' ?
|
||||
"Offizielle Website von Damjan Savić - AI & Automation Specialist. Spezialisiert auf KI-Agenten, Voice AI, Prozessautomatisierung mit n8n und Fullstack Development." :
|
||||
currentLanguage === 'sr' ?
|
||||
"Званична веб страница Дамјана Савића - AI & Automation Specialist. Специјализован за AI агенте, Voice AI, аутоматизацију процеса са n8n и Fullstack развој." :
|
||||
"Official website of Damjan Savić - AI & Automation Specialist. Specialized in AI agents, Voice AI, process automation with n8n, and Fullstack Development.",
|
||||
"publisher": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "ContactAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"url": "https://damjan-savic.com/contact"
|
||||
},
|
||||
"name": currentLanguage === 'de' ?
|
||||
"Damjan Savić kontaktieren" :
|
||||
currentLanguage === 'sr' ?
|
||||
"Контактирајте Дамјана Савића" :
|
||||
"Contact Damjan Savić"
|
||||
},
|
||||
"keywords": currentLanguage === 'de' ?
|
||||
"Damjan Savić, AI Automation Specialist, KI-Agenten, Voice AI, n8n, Prozessautomatisierung, TypeScript, Python, Next.js" :
|
||||
currentLanguage === 'sr' ?
|
||||
"Дамјан Савић, AI Automation Specialist, AI агенти, Voice AI, n8n, аутоматизација процеса, TypeScript, Python, Next.js" :
|
||||
"Damjan Savić, AI Automation Specialist, AI Agents, Voice AI, n8n, Process Automation, TypeScript, Python, Next.js",
|
||||
"dateCreated": "2020-01-01",
|
||||
"dateModified": "2025-01-15",
|
||||
"creator": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"copyrightHolder": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"copyrightYear": "2025",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.png",
|
||||
"width": "1000",
|
||||
"height": "1000",
|
||||
"caption": currentLanguage === 'sr' ? "Дамјан Савић лого" : "Damjan Savić Logo"
|
||||
},
|
||||
"image": "https://damjan-savic.com/logo.png",
|
||||
"inLanguage": currentLanguage === 'de' ? ["de-DE"] :
|
||||
currentLanguage === 'sr' ? ["sr-RS"] :
|
||||
["en-US"]
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function PersonSchema() {
|
||||
const personSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"@id": "https://damjan-savic.com/#person",
|
||||
"name": "Damjan Savić",
|
||||
"alternateName": "Damjan Savić",
|
||||
"jobTitle": ["Senior Fullstack Developer", "Digital Solutions Consultant", "Software Architect", "KI/AI Spezialist"],
|
||||
"description": "Damjan Savić ist ein Senior Fullstack Entwickler und Digital Solutions Consultant aus Köln. Damjan Savić ist spezialisiert auf Python, JavaScript, React, Next.js, TypeScript und KI-Integration. Mit über 10 Jahren Erfahrung entwickelt Damjan Savić maßgeschneiderte Enterprise-Lösungen, moderne Web-Applikationen und innovative KI-gestützte Systeme für Unternehmen jeder Größe.",
|
||||
"url": "https://damjan-savic.com",
|
||||
"image": [
|
||||
{
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/portrait.jpg",
|
||||
"caption": "Damjan Savić Portrait"
|
||||
},
|
||||
{
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.png",
|
||||
"caption": "Damjan Savić Logo"
|
||||
}
|
||||
],
|
||||
"logo": "https://damjan-savic.com/logo.png",
|
||||
"email": "info@damjan-savic.com",
|
||||
"telephone": "+49-XXX-XXXXXXX",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"email": "info@damjan-savic.com",
|
||||
"contactType": "Business Inquiries",
|
||||
"availableLanguage": ["de", "en", "sr"],
|
||||
"areaServed": ["DE", "EU"],
|
||||
"hoursAvailable": {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "09:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/damjansavic",
|
||||
"https://linkedin.com/in/damjansavic",
|
||||
"https://twitter.com/damjansavic",
|
||||
"https://stackoverflow.com/users/damjansavic"
|
||||
],
|
||||
"knowsAbout": [
|
||||
"Python Development",
|
||||
"JavaScript Development",
|
||||
"React.js",
|
||||
"Next.js",
|
||||
"TypeScript",
|
||||
"Electron Desktop Applications",
|
||||
"Künstliche Intelligenz (KI/AI)",
|
||||
"OLLAMA AI/ML Integration",
|
||||
"Machine Learning",
|
||||
"Large Language Models (LLM)",
|
||||
"ERP Systems Integration",
|
||||
"SAP Integration",
|
||||
"E-Commerce Development",
|
||||
"Shopify Development",
|
||||
"WooCommerce Integration",
|
||||
"Process Automation",
|
||||
"Workflow Optimization",
|
||||
"Backend Development",
|
||||
"Frontend Development",
|
||||
"Full Stack Development",
|
||||
"Cloud Architecture",
|
||||
"AWS Services",
|
||||
"Docker & Kubernetes",
|
||||
"Microservices Architecture",
|
||||
"DevOps & CI/CD",
|
||||
"Agile Development",
|
||||
"Software Architecture"
|
||||
],
|
||||
"hasSkill": [
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "Python Development",
|
||||
"description": "Damjan Savić bietet Expert-level Python programming for automation, backend development, and AI/ML applications"
|
||||
},
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "JavaScript/TypeScript Development",
|
||||
"description": "Full-stack JavaScript development with React, Next.js, Node.js, and TypeScript"
|
||||
},
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "AI/ML with OLLAMA",
|
||||
"description": "Implementation of AI solutions using OLLAMA for local language models"
|
||||
},
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "ERP & E-Commerce Integration",
|
||||
"description": "Custom ERP system development and e-commerce platform integration"
|
||||
},
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "Electron Desktop Development",
|
||||
"description": "Cross-platform desktop application development with Electron and modern web technologies"
|
||||
},
|
||||
{
|
||||
"@type": "DefinedTerm",
|
||||
"name": "Process Automation",
|
||||
"description": "Business process automation using Python, APIs, and custom integration solutions"
|
||||
}
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "Köln",
|
||||
"addressRegion": "NRW",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "CoderConda",
|
||||
"description": "Moderne Softwareentwicklung, KI-Integration und Prozessautomatisierung von Damjan Savić",
|
||||
"url": "https://damjan-savic.com",
|
||||
"founder": "Damjan Savić",
|
||||
"foundingDate": "2020",
|
||||
"slogan": "Innovative Lösungen für digitale Herausforderungen"
|
||||
},
|
||||
"alumniOf": [
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Technische Universität",
|
||||
"url": "https://www.tu.edu"
|
||||
}
|
||||
],
|
||||
"award": [
|
||||
"Certified Cloud Architect",
|
||||
"Python Professional Certification",
|
||||
"React Expert Certification"
|
||||
],
|
||||
"knowsLanguage": [
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "Deutsch",
|
||||
"alternateName": "de"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "English",
|
||||
"alternateName": "en"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "Srpski",
|
||||
"alternateName": "sr"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "Français",
|
||||
"alternateName": "fr"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "Español",
|
||||
"alternateName": "es"
|
||||
},
|
||||
{
|
||||
"@type": "Language",
|
||||
"name": "Russian",
|
||||
"alternateName": "ru"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Project {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
technologies: string[];
|
||||
dateCreated: string;
|
||||
url?: string;
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
screenshotUrl?: string;
|
||||
features?: string[];
|
||||
technicalChallenge?: string;
|
||||
businessImpact?: {
|
||||
roi?: string;
|
||||
performanceGain?: string;
|
||||
usersImpacted?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectSchemaProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectSchema({ project }: ProjectSchemaProps) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": project.title,
|
||||
"description": project.description,
|
||||
"applicationCategory": project.category,
|
||||
"operatingSystem": "Web-based",
|
||||
"programmingLanguage": project.technologies,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Damjan Savić",
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"dateCreated": project.dateCreated,
|
||||
"url": project.url || project.liveUrl,
|
||||
"screenshot": project.screenshotUrl,
|
||||
"featureList": project.features || [],
|
||||
"softwareRequirements": "Modern web browser with JavaScript enabled",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "EUR"
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function ServiceSchema() {
|
||||
const services = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"@id": "https://damjan-savic.com/#service-fullstack",
|
||||
"name": "Fullstack Web Development von Damjan Savić",
|
||||
"description": "Professionelle Fullstack-Entwicklung durch Damjan Savić. Moderne Webanwendungen mit React, Next.js, Python, Django, FastAPI und TypeScript. Von der Konzeption bis zum Deployment entwickelt Damjan Savić skalierbare Lösungen.",
|
||||
"provider": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"serviceType": "Software Development",
|
||||
"areaServed": ["Köln", "Deutschland", "Europa", "Weltweit"],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "Fullstack Development Services",
|
||||
"itemListElement": [
|
||||
"Frontend Development (React, Vue, Angular)",
|
||||
"Backend Development (Python, Node.js)",
|
||||
"Database Design (PostgreSQL, MongoDB)",
|
||||
"API Development (REST, GraphQL)",
|
||||
"Progressive Web Apps (PWA)",
|
||||
"Single Page Applications (SPA)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"@id": "https://damjan-savic.com/#service-ai",
|
||||
"name": "KI/AI Integration Services von Damjan Savić",
|
||||
"description": "Damjan Savić integriert künstliche Intelligenz in Ihre Geschäftsprozesse. Spezialisiert auf OLLAMA, Large Language Models (LLMs), Machine Learning und Computer Vision. Damjan Savić entwickelt maßgeschneiderte KI-Lösungen.",
|
||||
"provider": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"serviceType": "AI/ML Development",
|
||||
"areaServed": ["Köln", "Deutschland", "Europa", "Weltweit"],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "AI Integration Services",
|
||||
"itemListElement": [
|
||||
"OLLAMA Integration",
|
||||
"Large Language Model Implementation",
|
||||
"Natural Language Processing (NLP)",
|
||||
"Computer Vision Solutions",
|
||||
"Predictive Analytics",
|
||||
"AI-powered Automation"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"@id": "https://damjan-savic.com/#service-cloud",
|
||||
"name": "Cloud Architecture & DevOps von Damjan Savić",
|
||||
"description": "Cloud-native Lösungen und DevOps-Expertise von Damjan Savić. AWS, Azure, Google Cloud Platform, Docker, Kubernetes und CI/CD Pipelines. Damjan Savić migriert und optimiert Ihre Cloud-Infrastruktur.",
|
||||
"provider": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"serviceType": "Cloud Consulting",
|
||||
"areaServed": ["Köln", "Deutschland", "Europa", "Weltweit"],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "Cloud & DevOps Services",
|
||||
"itemListElement": [
|
||||
"AWS Architecture & Migration",
|
||||
"Docker Containerization",
|
||||
"Kubernetes Orchestration",
|
||||
"CI/CD Pipeline Setup",
|
||||
"Infrastructure as Code (IaC)",
|
||||
"Cloud Cost Optimization"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"@id": "https://damjan-savic.com/#service-ecommerce",
|
||||
"name": "E-Commerce Development von Damjan Savić",
|
||||
"description": "Damjan Savić entwickelt moderne E-Commerce-Lösungen. Shopify, WooCommerce, Magento und Custom-Shops. Von der Produktverwaltung bis zur Payment-Integration bietet Damjan Savić vollständige E-Commerce-Services.",
|
||||
"provider": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"serviceType": "E-Commerce Development",
|
||||
"areaServed": ["Köln", "Deutschland", "Europa", "Weltweit"],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "E-Commerce Services",
|
||||
"itemListElement": [
|
||||
"Shopify Store Development",
|
||||
"WooCommerce Integration",
|
||||
"Payment Gateway Integration",
|
||||
"Inventory Management Systems",
|
||||
"Order Processing Automation",
|
||||
"Multi-Channel Commerce"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"@id": "https://damjan-savic.com/#service-automation",
|
||||
"name": "Process Automation von Damjan Savić",
|
||||
"description": "Geschäftsprozess-Automatisierung durch Damjan Savić. Python-basierte Automatisierungslösungen, Workflow-Optimierung und Integration von Systemen. Damjan Savić steigert Ihre Effizienz durch intelligente Automatisierung.",
|
||||
"provider": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"serviceType": "Business Process Automation",
|
||||
"areaServed": ["Köln", "Deutschland", "Europa", "Weltweit"],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "Automation Services",
|
||||
"itemListElement": [
|
||||
"Python Automation Scripts",
|
||||
"Workflow Automation",
|
||||
"Data Processing Pipelines",
|
||||
"API Integration & Orchestration",
|
||||
"Report Generation & Analytics",
|
||||
"Task Scheduling & Monitoring"
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{services.map((service, index) => (
|
||||
<script
|
||||
key={index}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(service) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function WebsiteSchema() {
|
||||
const websiteSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": "https://damjan-savic.com/#website",
|
||||
"url": "https://damjan-savic.com",
|
||||
"name": "Damjan Savić - Senior Fullstack Developer & Digital Solutions Consultant",
|
||||
"alternateName": ["Damjan Savić Portfolio", "CoderConda"],
|
||||
"description": "Offizielle Website von Damjan Savić - Senior Fullstack Entwickler und Digital Solutions Consultant aus Köln. Spezialisiert auf Enterprise Software Development, KI-Integration, Cloud Architecture und moderne Web-Technologien. Entdecken Sie innovative Lösungen von Damjan Savić.",
|
||||
"publisher": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "ContactAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"url": "https://damjan-savic.com/contact"
|
||||
},
|
||||
"name": "Damjan Savić kontaktieren"
|
||||
},
|
||||
"keywords": "Damjan Savić, Senior Fullstack Developer, Digital Solutions Consultant, Software Architect Köln, Python Experte, React Spezialist, KI Integration",
|
||||
"dateCreated": "2020-01-01",
|
||||
"dateModified": "2025-01-15",
|
||||
"creator": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"copyrightHolder": {
|
||||
"@id": "https://damjan-savic.com/#person"
|
||||
},
|
||||
"copyrightYear": "2025",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.png",
|
||||
"width": "1000",
|
||||
"height": "1000",
|
||||
"caption": "Damjan Savić Logo"
|
||||
},
|
||||
"image": "https://damjan-savic.com/logo.png",
|
||||
"inLanguage": ["de-DE", "en-US", "sr-RS"]
|
||||
};
|
||||
|
||||
const breadcrumbSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Damjan Savić",
|
||||
"item": "https://damjan-savic.com"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Über Damjan Savić",
|
||||
"item": "https://damjan-savic.com/about"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "Portfolio von Damjan Savić",
|
||||
"item": "https://damjan-savic.com/portfolio"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "Blog von Damjan Savić",
|
||||
"item": "https://damjan-savic.com/blog"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 5,
|
||||
"name": "Kontakt Damjan Savić",
|
||||
"item": "https://damjan-savic.com/contact"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const faqSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Wer ist Damjan Savić?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Damjan Savić ist ein Senior Fullstack Entwickler und Digital Solutions Consultant aus Köln mit über 10 Jahren Erfahrung. Damjan Savić ist spezialisiert auf Enterprise Software Development, Cloud Architecture, KI-Integration mit OLLAMA und moderne Web-Technologien wie Python, React, TypeScript und Next.js. Als Software Architect entwickelt Damjan Savić skalierbare Lösungen für komplexe Geschäftsanforderungen."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Welche Dienstleistungen bietet Damjan Savić an?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Damjan Savić bietet umfassende IT-Dienstleistungen: Enterprise Software Development, Cloud-native Lösungen (AWS, Azure), Microservices Architecture, KI/ML-Integration mit OLLAMA und LLMs, SAP/ERP-Systemintegration, E-Commerce-Plattformen (Shopify, WooCommerce), Business Process Automation, DevOps & CI/CD, Progressive Web Apps, Electron Desktop-Anwendungen und Digital Transformation Consulting. Damjan Savić arbeitet mit modernsten Technologien wie Python, React, TypeScript, Docker, Kubernetes und mehr."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Wie kann ich Damjan Savić kontaktieren?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Sie können Damjan Savić auf verschiedenen Wegen erreichen: Über das Kontaktformular auf https://damjan-savic.com/contact, per E-Mail an info@damjan-savic.com, auf LinkedIn unter https://linkedin.com/in/damjansavic, auf GitHub unter https://github.com/damjansavic oder telefonisch während der Geschäftszeiten (Mo-Fr, 9-18 Uhr MEZ). Damjan Savić antwortet in der Regel innerhalb von 24 Stunden auf Anfragen."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Wo ist Damjan Savić ansässig?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Damjan Savić ist in Köln, Nordrhein-Westfalen, Deutschland ansässig. Als erfahrener Remote-First Developer arbeitet Damjan Savić erfolgreich mit Kunden aus ganz Deutschland, Europa und weltweit zusammen. Damjan Savić bietet flexible Zusammenarbeitsmodelle: vor Ort in Köln und Umgebung, hybrid oder vollständig remote. Die Arbeitssprachen von Damjan Savić sind Deutsch, Englisch und Serbisch."
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user