First Commit - Portfolio Page
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
© {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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user