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