First Commit - Portfolio Page

This commit is contained in:
2025-02-10 00:55:39 +01:00
commit bba0e331a8
239 changed files with 33355 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
// 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>;
};
+41
View File
@@ -0,0 +1,41 @@
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 }
+77
View File
@@ -0,0 +1,77 @@
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;
+60
View File
@@ -0,0 +1,60 @@
// 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';
+84
View File
@@ -0,0 +1,84 @@
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 }
+36
View File
@@ -0,0 +1,36 @@
"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>
)
}
+125
View File
@@ -0,0 +1,125 @@
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;
+46
View File
@@ -0,0 +1,46 @@
import { Phone, Mail, ArrowRight } from "lucide-react"
import { Link } from "react-router-dom"
import { motion } from "framer-motion"
export function ContactInfo() {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mb-4"
>
<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-500 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-500 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>
</motion.div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import CookieConsent from 'react-cookie-consent';
import { useTranslation } from 'react-i18next';
const CookieBanner = () => {
const { t } = useTranslation();
return (
<CookieConsent
location="bottom"
buttonText={t('common.cookies.accept')}
declineButtonText={t('common.cookies.decline')}
enableDeclineButton
style={{
background: '#1a1a1a',
borderTop: '1px solid rgba(255, 255, 255, 0.1)'
}}
buttonStyle={{
background: '#ff6b00',
color: 'white',
fontSize: '14px',
borderRadius: '6px',
padding: '8px 16px',
}}
declineButtonStyle={{
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: 'white',
fontSize: '14px',
borderRadius: '6px',
padding: '8px 16px',
}}
expires={365}
onAccept={() => {
// Initialize analytics only after consent
window.initializeAnalytics();
}}
>
{t('common.cookies.message')}
</CookieConsent>
);
};
export default CookieBanner;
+61
View File
@@ -0,0 +1,61 @@
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;
+45
View File
@@ -0,0 +1,45 @@
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">
<svg className="w-full h-full text-accent" viewBox="0 0 696 316" fill="none">
<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",
}}
/>
))}
</svg>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
// 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="bg-zinc-900 pt-16 pb-8 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 mb-12">
{/* Contact Section */}
<div className="space-y-4">
<h3 className="text-white text-lg font-semibold mb-4">
{t('footer.sections.contact.title')}
</h3>
<div className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors">
<Mail size={18} />
<a
href={`mailto:${t('footer.sections.contact.email')}`}
className="hover:underline"
aria-label={t('footer.sections.contact.aria.emailLink')}
>
{t('footer.sections.contact.email')}
</a>
</div>
<div className="flex items-center space-x-2 text-zinc-400">
<MapPin size={18} />
<span aria-label={t('footer.sections.contact.aria.locationText')}>
{t('footer.sections.contact.location')}
</span>
</div>
</div>
{/* Navigation Section */}
<div className="space-y-4">
<h3 className="text-white text-lg font-semibold mb-4">
{t('footer.sections.navigation.title')}
</h3>
<ul className="space-y-2">
<li>
<Link to="/portfolio" className="text-zinc-400 hover:text-white transition-colors">
{t('footer.sections.navigation.links.portfolio')}
</Link>
</li>
<li>
<Link to="/blog" className="text-zinc-400 hover:text-white transition-colors">
{t('footer.sections.navigation.links.blog')}
</Link>
</li>
<li>
<Link to="/about" className="text-zinc-400 hover:text-white transition-colors">
{t('footer.sections.navigation.links.about')}
</Link>
</li>
<li>
<Link to="/contact" className="text-zinc-400 hover:text-white transition-colors">
{t('footer.sections.navigation.links.contact')}
</Link>
</li>
</ul>
</div>
{/* Social Media Section */}
<div className="space-y-4">
<h3 className="text-white text-lg font-semibold mb-4">
{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="text-zinc-400 hover:text-white transition-colors"
aria-label={t('footer.sections.social.aria.linkedin')}
>
<Linkedin size={24} />
</a>
<a
href="https://github.com/damjan1996"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white transition-colors"
aria-label={t('footer.sections.social.aria.github')}
>
<Github size={24} />
</a>
</div>
</div>
</div>
{/* Legal Section */}
<div className="border-t border-zinc-800 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-zinc-400 text-sm">
&copy; {currentYear} Damjan Savić. {t('footer.legal.copyright')}
</p>
<div className="flex space-x-6">
<Link to="/privacy" className="text-zinc-400 hover:text-white text-sm transition-colors">
{t('footer.legal.links.privacy')}
</Link>
<Link to="/terms" className="text-zinc-400 hover:text-white text-sm transition-colors">
{t('footer.legal.links.terms')}
</Link>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;
+61
View File
@@ -0,0 +1,61 @@
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;
+25
View File
@@ -0,0 +1,25 @@
// 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';
+15
View File
@@ -0,0 +1,15 @@
// 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>
);
};
+97
View File
@@ -0,0 +1,97 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const LanguageSwitcher: React.FC = () => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = 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);
}
};
React.useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative" ref={dropdownRef}>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="flex items-center space-x-2 text-zinc-400 hover:text-white
px-4 py-2 rounded-full transition-colors duration-200
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800"
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>
</motion.button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-48 rounded-lg overflow-hidden
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm
shadow-lg"
role="listbox"
aria-label="Languages"
onKeyDown={handleKeyDown}
>
<div className="py-1">
{languages.map((lang) => (
<motion.button
key={lang.code}
whileHover={{ backgroundColor: 'rgba(39, 39, 42, 0.5)' }}
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
${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}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default LanguageSwitcher;
+167
View File
@@ -0,0 +1,167 @@
// components/Layout.tsx
import { useEffect, useState, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Home, User, Briefcase, BookOpen, Phone, LayoutDashboard } from "lucide-react";
import { supabase } from "../utils/supabaseClient";
import { ContactInfo } from "../components/ContactInfo";
import { NavLink } from "../components/NavLink";
import LanguageSwitcher from "./LanguageSwitcher";
import { Link } from "react-router-dom";
import Footer from "./Footer";
interface LayoutProps {
children: ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
const { t } = useTranslation();
const [isAdmin, setIsAdmin] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const checkAdmin = async () => {
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);
}
};
checkAdmin();
const handleScroll = () => {
setScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
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" />,
},
]
: []),
];
return (
<div className="min-h-screen bg-zinc-900 flex flex-col">
<nav
className={`
fixed w-full z-50 transition-all duration-300
${scrolled
? "bg-zinc-900/80 backdrop-blur supports-[backdrop-filter]:bg-zinc-900/80"
: "bg-transparent"
}
`}
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">
<Link to="/" className="flex items-center space-x-2 group shrink-0">
<motion.img
src="/logo.png"
alt=""
className="h-8 w-auto"
whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
/>
</Link>
<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>
<motion.button
className="md:hidden p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? "Close menu" : "Open menu"}
whileTap={{ scale: 0.95 }}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</motion.button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 md:hidden"
onClick={() => setIsOpen(false)}
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-zinc-900 shadow-xl md:hidden overflow-y-auto border-l border-zinc-800"
>
<div className="flex flex-col h-full">
<ContactInfo />
<div className="flex-1 py-4 px-4">
{navigationLinks.map(({ path, label, icon }) => (
<NavLink
key={path}
to={path}
icon={icon}
label={label}
onClick={() => setIsOpen(false)}
className="flex items-center w-full mb-1"
/>
))}
</div>
<div className="border-t border-zinc-800 p-6">
<div className="flex items-center justify-between">
<span className="text-sm text-zinc-500">Sprache ändern</span>
<LanguageSwitcher />
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</nav>
<main className="flex-1 pt-16">{children}</main>
<Footer />
</div>
);
};
export default Layout;
+17
View File
@@ -0,0 +1,17 @@
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;
+102
View File
@@ -0,0 +1,102 @@
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;
+28
View File
@@ -0,0 +1,28 @@
// 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>;
}
+47
View File
@@ -0,0 +1,47 @@
// components/NavLink.tsx
import { Link, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
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' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
${className}
`}
>
{icon}
<span className="text-sm tracking-wide">{label}</span>
{isActive && (
<motion.div
layoutId="activeIndicator"
className="absolute inset-0 rounded-full bg-zinc-800/50 -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
/>
)}
</Link>
)
}
+126
View File
@@ -0,0 +1,126 @@
"use client"
import type React from "react"
import { useEffect, useState } from "react"
import { motion, AnimatePresence, cubicBezier } from "framer-motion"
import { useLocation } from "react-router-dom"
interface PageTransitionProps {
children: React.ReactNode
}
const customEase = cubicBezier(0.25, 0.1, 0.25, 1)
const Logo = () => (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1,
transition: { duration: 0.3, ease: customEase },
}}
exit={{
opacity: 0,
scale: 1.2,
transition: { duration: 0.3, ease: customEase },
}}
className="w-16 h-16"
>
<img
src="/logo.png"
alt="Logo"
className="w-full h-full object-contain filter brightness-200"
/>
</motion.div>
)
const PageTransition = ({ children }: PageTransitionProps) => {
const location = useLocation()
const [isTransitioning, setIsTransitioning] = useState(true)
const [isOverlayActive, setIsOverlayActive] = useState(true)
useEffect(() => {
setIsTransitioning(true)
setIsOverlayActive(true)
const overlayTimer = setTimeout(() => {
setIsOverlayActive(false)
}, 1200) // Overlay animation duration
const transitionTimer = setTimeout(() => {
setIsTransitioning(false)
}, 800)
return () => {
clearTimeout(overlayTimer)
clearTimeout(transitionTimer)
}
}, [location])
return (
<AnimatePresence mode="wait">
<motion.div key={location.pathname} className="relative">
{/* Transition Container */}
<div className={`fixed inset-0 pointer-events-none ${isOverlayActive ? 'z-50' : '-z-10'}`}>
{/* Overlay Animation */}
<motion.div
className="absolute inset-0 bg-zinc-900"
initial={{ opacity: 0 }}
animate={{
opacity: [0, 1, 1, 0],
transition: {
duration: 1.2,
times: [0, 0.3, 0.7, 1],
ease: customEase,
},
}}
/>
{/* Logo */}
<AnimatePresence>
{isTransitioning && (
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
delay: 0.3,
duration: 0.3,
},
}}
exit={{
opacity: 0,
transition: {
duration: 0.3,
},
}}
>
<Logo />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Page Content */}
<motion.div
className="relative"
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
transition: {
duration: 0.4,
delay: 0.8,
ease: customEase,
},
}}
exit={{ opacity: 0, y: -20, transition: { duration: 0.3 } }}
>
{children}
</motion.div>
</motion.div>
</AnimatePresence>
)
}
export default PageTransition
+55
View File
@@ -0,0 +1,55 @@
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
+171
View File
@@ -0,0 +1,171 @@
// 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;
+44
View File
@@ -0,0 +1,44 @@
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}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
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;
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
interface SEOProps {
title?: string;
description?: string;
image?: string;
article?: boolean;
schema?: object;
}
const SEO: React.FC<SEOProps> = ({
title,
description = 'JTL Integration Expert and Digital Commerce Specialist with extensive experience in e-commerce solutions and warehouse management systems.',
image = 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
article = false,
schema,
}) => {
// 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;
// Default schema für die Website
const defaultSchema = {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Damjan Savić',
url: siteUrl,
image: image,
description: description,
sameAs: [
'https://linkedin.com/in/damjansavic',
'https://github.com/damjansavic'
],
jobTitle: 'JTL Integration Expert',
worksFor: {
'@type': 'Organization',
name: 'Independent Consultant'
},
knowsAbout: [
'JTL-Wawi',
'E-commerce',
'Warehouse Management Systems',
'Digital Commerce',
'System Integration'
]
};
// 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,
href: `${siteUrl}/${lng}${window.location.pathname}`,
}))
: [];
return (
<Helmet>
{/* Basic meta tags */}
<html lang={currentLanguage} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="image" content={image} />
<link rel="canonical" href={currentUrl} />
{/* Language alternates */}
{alternateUrls?.map(
({ hrefLang, href }: { hrefLang: string; href: string }) => (
<link key={hrefLang} rel="alternate" hrefLang={hrefLang} href={href} />
)
)}
<link rel="alternate" hrefLang="x-default" href={siteUrl} />
{/* Open Graph meta tags */}
<meta property="og:url" content={currentUrl} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<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} />
{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={description} />
<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="JTL, E-commerce, Warehouse Management, Digital Commerce, System Integration, JTL-Wawi, WMS"
/>
<meta name="author" content="Damjan Savić" />
{/* Schema.org markup */}
<script type="application/ld+json">
{JSON.stringify(schema || defaultSchema)}
</script>
</Helmet>
);
};
export default SEO;
+44
View File
@@ -0,0 +1,44 @@
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
+104
View File
@@ -0,0 +1,104 @@
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
+52
View File
@@ -0,0 +1,52 @@
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>
);
+60
View File
@@ -0,0 +1,60 @@
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;
+23
View File
@@ -0,0 +1,23 @@
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
+51
View File
@@ -0,0 +1,51 @@
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;
+29
View File
@@ -0,0 +1,29 @@
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;