auto-claude: subtask-2-1 - Delete src/components-vite directory (299KB, 50 fi
This commit is contained in:
@@ -1,43 +0,0 @@
|
||||
// Alert.tsx
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
interface AlertProps {
|
||||
variant?: 'success' | 'error';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Alert: React.FC<AlertProps> = ({ variant = 'success', children }) => {
|
||||
const variants = {
|
||||
success: {
|
||||
bg: 'bg-primary/10',
|
||||
border: 'border-primary',
|
||||
text: 'text-primary',
|
||||
icon: <CheckCircle2 className="h-5 w-5" />
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
text: 'text-red-400',
|
||||
icon: <XCircle className="h-5 w-5" />
|
||||
}
|
||||
};
|
||||
|
||||
const style = variants[variant];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`${style.bg} ${style.border} ${style.text} border rounded-lg p-4 flex items-start space-x-3`}
|
||||
>
|
||||
<span className="flex-shrink-0">{style.icon}</span>
|
||||
<div className="flex-1">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertDescription: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <div className="text-sm">{children}</div>;
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
currentTitle?: string;
|
||||
}
|
||||
|
||||
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ currentTitle }) => {
|
||||
const location = useLocation();
|
||||
|
||||
// Definiere die feste Struktur der Breadcrumbs
|
||||
const generateBreadcrumbs = () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Basis-Breadcrumb-Struktur
|
||||
const breadcrumbs = [];
|
||||
|
||||
// Portfolio ist immer der erste Level nach Home
|
||||
if (segments.includes('portfolio')) {
|
||||
breadcrumbs.push({
|
||||
title: 'Portfolio',
|
||||
path: '/portfolio',
|
||||
isLast: segments.length === 1
|
||||
});
|
||||
}
|
||||
|
||||
// Wenn wir in einem Projekt sind, füge den Projekttitel hinzu
|
||||
if (segments.length > 1 && currentTitle) {
|
||||
breadcrumbs.push({
|
||||
title: currentTitle,
|
||||
path: location.pathname,
|
||||
isLast: true
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs();
|
||||
|
||||
// Wenn wir nur auf der Homepage sind, zeigen wir keine Breadcrumbs
|
||||
if (location.pathname === '/') return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 py-4 sm:py-6">
|
||||
{/* Home ist immer der erste Link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<React.Fragment key={crumb.path}>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600" />
|
||||
|
||||
{crumb.isLast ? (
|
||||
<span className="text-white font-medium">
|
||||
{crumb.title}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={crumb.path}
|
||||
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{crumb.title}
|
||||
</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
||||
@@ -1,60 +0,0 @@
|
||||
// Button.tsx
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isLoading?: boolean;
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
isLoading,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary text-white hover:bg-primary/90',
|
||||
outline: 'border border-primary text-primary hover:bg-primary hover:text-white',
|
||||
ghost: 'text-primary hover:bg-primary/10'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-9 px-4 text-sm',
|
||||
md: 'h-12 px-6 text-base',
|
||||
lg: 'h-14 px-8 text-lg'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={isLoading || disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export function ContactBar() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50 }}
|
||||
animate={{ y: 0 }}
|
||||
className="bg-gradient-to-r from-cyan-900 to-slate-900 text-white py-2 px-4"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<a href="tel:+1234567890" className="flex items-center space-x-2 hover:text-cyan-400 transition-colors">
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>+1 234 567 890</span>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="flex items-center space-x-2 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>info@damjan-savic.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<Link to="/contact" className="flex items-center space-x-1 text-sm hover:text-cyan-400 transition-colors group">
|
||||
<span>Contact Us</span>
|
||||
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Send, AlertCircle } from 'lucide-react';
|
||||
import { getCsrfToken } from '../utils/csrf';
|
||||
import { rateLimiter } from '../utils/rateLimiting';
|
||||
|
||||
interface ContactFormProps {
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
const ContactForm: React.FC<ContactFormProps> = ({ onSubmit }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check rate limiting
|
||||
const clientIp = '127.0.0.1'; // In production, get this from the request
|
||||
if (rateLimiter.isRateLimited(clientIp)) {
|
||||
const timeToReset = Math.ceil(rateLimiter.getTimeToReset(clientIp) / 1000 / 60);
|
||||
throw new Error(`Too many attempts. Please try again in ${timeToReset} minutes.`);
|
||||
}
|
||||
|
||||
// Validate CSRF token
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('email', email);
|
||||
formData.append('message', message);
|
||||
formData.append('csrf_token', getCsrfToken());
|
||||
|
||||
await onSubmit(formData);
|
||||
setSuccess(true);
|
||||
setName('');
|
||||
setEmail('');
|
||||
setMessage('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 border border-green-500/20 text-green-500 p-4 rounded-lg">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-white/80 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Sending...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center">
|
||||
<Send className="h-5 w-5 mr-2" />
|
||||
Send Message
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export function ContactInfo() {
|
||||
return (
|
||||
<div className="pb-6 px-6 pt-4 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mt-2 animate-fade-in">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-white tracking-wider uppercase">
|
||||
Kontakt
|
||||
</h3>
|
||||
|
||||
<a
|
||||
href="tel:+1234567890"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>+1 234 567 890</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>info@damjan-savic.com</span>
|
||||
</a>
|
||||
|
||||
<Link
|
||||
to="/contact"
|
||||
className="flex items-center justify-between mt-6 px-4 py-2 bg-zinc-800/50
|
||||
hover:bg-zinc-800 rounded-full text-sm text-zinc-400 hover:text-white
|
||||
transition-all duration-200 group border border-zinc-800 hover:border-zinc-700"
|
||||
>
|
||||
<span className="tracking-wide">Kontakt aufnehmen</span>
|
||||
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { initGA, logPageView, initFBPixel, initFunctionalCookies } from '../utils/analytics';
|
||||
import { loadGoogleTagManager } from '../services/gtm';
|
||||
|
||||
// Feste Übersetzungen direkt in der Komponente
|
||||
const translations = {
|
||||
de: {
|
||||
title: "Cookie-Einstellungen",
|
||||
message: "Wir nutzen Cookies zur Verbesserung der Website. Ohne Ihre Zustimmung keine Datenweitergabe an Dritte. ",
|
||||
acceptAll: "Alle akzeptieren",
|
||||
save: "Einstellungen speichern",
|
||||
decline: "Ablehnen",
|
||||
necessary: "Notwendige Cookies",
|
||||
necessaryDesc: "Für grundlegende Funktionen der Website",
|
||||
analytics: "Analyse-Cookies",
|
||||
analyticsDesc: "Für Analysen zur Verbesserung der Website",
|
||||
marketing: "Marketing-Cookies",
|
||||
marketingDesc: "Für personalisierte Werbung und Inhalte",
|
||||
functional: "Funktionale Cookies",
|
||||
functionalDesc: "Für erweiterte Funktionen und Personalisierung",
|
||||
learnMore: "Mehr erfahren",
|
||||
alwaysActive: "Immer aktiv"
|
||||
},
|
||||
en: {
|
||||
title: "Cookie Settings",
|
||||
message: "This website uses cookies to enhance your browsing experience. Data will not be shared with third parties without your consent.",
|
||||
acceptAll: "Accept All",
|
||||
save: "Save Settings",
|
||||
decline: "Decline All",
|
||||
necessary: "Necessary Cookies",
|
||||
necessaryDesc: "For basic website functions",
|
||||
analytics: "Analytics Cookies",
|
||||
analyticsDesc: "For analyzing website usage",
|
||||
marketing: "Marketing Cookies",
|
||||
marketingDesc: "For personalized ads and content",
|
||||
functional: "Functional Cookies",
|
||||
functionalDesc: "For enhanced functionality and personalization",
|
||||
learnMore: "Learn more",
|
||||
alwaysActive: "Always active"
|
||||
},
|
||||
sr: {
|
||||
title: "Podešavanja kolačića",
|
||||
message: "Ovaj sajt koristi kolačiće za bolje iskustvo pretraživanja. Podaci neće biti deljeni sa trećim licima bez vaše saglasnosti.",
|
||||
acceptAll: "Prihvati sve",
|
||||
save: "Sačuvaj podešavanja",
|
||||
decline: "Odbij sve",
|
||||
necessary: "Neophodni kolačići",
|
||||
necessaryDesc: "Za osnovne funkcije sajta",
|
||||
analytics: "Analitički kolačići",
|
||||
analyticsDesc: "Za analizu korišćenja sajta",
|
||||
marketing: "Marketing kolačići",
|
||||
marketingDesc: "Za personalizovane reklame i sadržaj",
|
||||
functional: "Funkcionalni kolačići",
|
||||
functionalDesc: "Za napredne funkcije i personalizaciju",
|
||||
learnMore: "Saznajte više",
|
||||
alwaysActive: "Uvek aktivno"
|
||||
}
|
||||
};
|
||||
|
||||
// Definition der verschiedenen Services
|
||||
const cookieServices = {
|
||||
essentiell: {
|
||||
category: 'necessary',
|
||||
services: [
|
||||
{
|
||||
name: 'Session Cookies',
|
||||
provider: 'Eigentümer der Website',
|
||||
purpose: 'Speichert Ihre Sitzungsinformationen'
|
||||
}
|
||||
]
|
||||
},
|
||||
funktional: {
|
||||
category: 'functional',
|
||||
services: [
|
||||
{
|
||||
name: 'Spracheinstellungen',
|
||||
provider: 'Eigentümer der Website',
|
||||
purpose: 'Speichert Ihre bevorzugte Sprache'
|
||||
},
|
||||
{
|
||||
name: 'Google Fonts',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Anzeige von Webschriften'
|
||||
}
|
||||
]
|
||||
},
|
||||
analyse: {
|
||||
category: 'analytics',
|
||||
services: [
|
||||
{
|
||||
name: 'Google Analytics',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Analysiert Webseitennutzung und Nutzerverhalten'
|
||||
}
|
||||
]
|
||||
},
|
||||
marketing: {
|
||||
category: 'marketing',
|
||||
services: [
|
||||
{
|
||||
name: 'Google Ads',
|
||||
provider: 'Google LLC',
|
||||
purpose: 'Personalisierte Werbung'
|
||||
},
|
||||
{
|
||||
name: 'Facebook Pixel',
|
||||
provider: 'Meta Platforms Inc.',
|
||||
purpose: 'Tracking von Werbekonversionen'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
interface CookieSettings {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
}
|
||||
|
||||
// Entfernt externe Google Fonts, falls keine Zustimmung vorliegt
|
||||
const removeExternalGoogleFonts = () => {
|
||||
// Suche nach allen Google Fonts Link-Elementen und entferne sie
|
||||
const linkElements = document.querySelectorAll('link[href*="fonts.googleapis.com"]');
|
||||
linkElements.forEach(link => {
|
||||
link.parentNode?.removeChild(link);
|
||||
});
|
||||
|
||||
// Suche nach allen Google Fonts Script-Elementen und entferne sie
|
||||
const scriptElements = document.querySelectorAll('script[src*="fonts.googleapis.com"]');
|
||||
scriptElements.forEach(script => {
|
||||
script.parentNode?.removeChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
// Funktion zum Blockieren von Google Analytics falls noch keine Zustimmung vorliegt
|
||||
const blockGoogleAnalytics = () => {
|
||||
// Entferne alle bestehenden GA Cookies
|
||||
document.cookie.split(';').forEach(function(c) {
|
||||
if (c.trim().indexOf('_ga') === 0) {
|
||||
document.cookie = c.trim().split('=')[0] + '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const CookieBanner = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [currentLang, setCurrentLang] = useState<'de' | 'en' | 'sr'>('de');
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [cookieSettings, setCookieSettings] = useState<CookieSettings>({
|
||||
necessary: true, // Always true and disabled
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false
|
||||
});
|
||||
|
||||
// Initialisiere Dienste basierend auf den Cookie-Einstellungen
|
||||
const initializeServices = useCallback((settings: CookieSettings) => {
|
||||
// Blockieren von Diensten, wenn nicht zugestimmt wurde
|
||||
if (!settings.functional) {
|
||||
removeExternalGoogleFonts();
|
||||
}
|
||||
|
||||
if (!settings.analytics) {
|
||||
blockGoogleAnalytics();
|
||||
} else {
|
||||
// Initialisiere Google Analytics nur wenn ausdrücklich zugestimmt wurde
|
||||
initGA();
|
||||
logPageView();
|
||||
}
|
||||
|
||||
// Marketing-Dienste nur mit Zustimmung
|
||||
if (settings.marketing) {
|
||||
initFBPixel();
|
||||
loadGoogleTagManager();
|
||||
}
|
||||
|
||||
// Funktionale Dienste nur mit Zustimmung
|
||||
if (settings.functional) {
|
||||
initFunctionalCookies();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Aktuelle Sprache erkennen
|
||||
useEffect(() => {
|
||||
const lang = i18n.language.substring(0, 2);
|
||||
if (lang === 'de' || lang === 'en' || lang === 'sr') {
|
||||
setCurrentLang(lang);
|
||||
} else {
|
||||
setCurrentLang('en'); // Fallback to English
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
// Get text based on current language
|
||||
const getText = (key: keyof typeof translations.en) => {
|
||||
return translations[currentLang][key];
|
||||
};
|
||||
|
||||
// DSGVO: Unmittelbar beim ersten Laden blockiere alle nicht-essentiellen Dienste
|
||||
useEffect(() => {
|
||||
// Blockiere externe Dienste bis Zustimmung vorliegt
|
||||
removeExternalGoogleFonts();
|
||||
blockGoogleAnalytics();
|
||||
}, []);
|
||||
|
||||
// Prüfe beim Laden, ob bereits Zustimmung vorhanden ist
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedSettings = localStorage.getItem('cookieSettings');
|
||||
const hasConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (storedSettings && hasConsent) {
|
||||
const parsedSettings = JSON.parse(storedSettings) as CookieSettings;
|
||||
setCookieSettings(parsedSettings);
|
||||
|
||||
// Lade die entsprechenden Dienste basierend auf den Einstellungen
|
||||
initializeServices(parsedSettings);
|
||||
setShowBanner(false);
|
||||
} else {
|
||||
// Zeige Banner wenn keine Zustimmung vorhanden ist
|
||||
setShowBanner(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cookie settings:', error);
|
||||
setShowBanner(true);
|
||||
}
|
||||
}, [initializeServices]);
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(cookieSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
|
||||
// Initialisiere Dienste basierend auf den ausgewählten Einstellungen
|
||||
initializeServices(cookieSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
const allSettings: CookieSettings = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true
|
||||
};
|
||||
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(allSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setCookieSettings(allSettings);
|
||||
|
||||
// Initialisiere alle Dienste
|
||||
initializeServices(allSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const handleDeclineAll = () => {
|
||||
const minimalSettings: CookieSettings = {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false
|
||||
};
|
||||
|
||||
localStorage.setItem('cookieSettings', JSON.stringify(minimalSettings));
|
||||
localStorage.setItem('cookieConsent', 'true');
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setCookieSettings(minimalSettings);
|
||||
|
||||
// Block all non-essential services
|
||||
initializeServices(minimalSettings);
|
||||
|
||||
setShowSettings(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const toggleSetting = (setting: keyof Omit<CookieSettings, 'necessary'>) => {
|
||||
setCookieSettings({
|
||||
...cookieSettings,
|
||||
[setting]: !cookieSettings[setting]
|
||||
});
|
||||
};
|
||||
|
||||
// If banner should not be shown, return null
|
||||
if (!showBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||
<div
|
||||
className="bg-[rgba(18,18,18,0.95)] backdrop-blur-md border-t border-white/5 text-white p-4 md:p-6"
|
||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{!showSettings ? (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm md:text-base font-light">{getText('message')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 mt-3 md:mt-0 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="w-full sm:w-auto px-4 py-2 text-xs uppercase tracking-wider font-light bg-white text-black hover:bg-gray-200 transition-all"
|
||||
>
|
||||
{getText('acceptAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">{getText('title')}</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-white/80 mb-4">{getText('message')}</p>
|
||||
|
||||
{/* Cookie categories */}
|
||||
<div className="space-y-4">
|
||||
{/* Necessary cookies - always enabled */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('necessary')}</div>
|
||||
<div className="text-xs text-white/60">{getText('alwaysActive')}</div>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('necessaryDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.essentiell.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('analytics')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.analytics}
|
||||
onChange={() => toggleSetting('analytics')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('analyticsDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.analyse.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('marketing')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.marketing}
|
||||
onChange={() => toggleSetting('marketing')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('marketingDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.marketing.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Functional cookies */}
|
||||
<div className="p-3 border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{getText('functional')}</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={cookieSettings.functional}
|
||||
onChange={() => toggleSetting('functional')}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-white/50"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-white/70">{getText('functionalDesc')}</p>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="mt-2 border-t border-white/10 pt-2">
|
||||
{cookieServices.funktional.services.map((service, index) => (
|
||||
<div key={index} className="mt-2">
|
||||
<div className="text-xs font-medium text-white/80">{service.name}</div>
|
||||
<div className="text-xs text-white/60">Anbieter: {service.provider}</div>
|
||||
<div className="text-xs text-white/60">Zweck: {service.purpose}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="px-4 py-2 text-xs uppercase tracking-wider font-light text-white border border-white/30 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{getText('decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
className="px-4 py-2 text-xs uppercase tracking-wider font-light bg-white text-black hover:bg-gray-200 transition-all"
|
||||
>
|
||||
{getText('save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center space-x-4">
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-xs text-white/70 hover:text-white underline"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</a>
|
||||
<a
|
||||
href="/imprint"
|
||||
className="text-xs text-white/70 hover:text-white underline"
|
||||
>
|
||||
Impressum
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useScrollContext } from './ScrollContext';
|
||||
|
||||
interface KeyboardEvent {
|
||||
key: string;
|
||||
}
|
||||
|
||||
const DebugOverlay: React.FC = () => {
|
||||
const [showDebug, setShowDebug] = useState<boolean>(false);
|
||||
const { isTransitioning } = useScrollContext();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'd') {
|
||||
setShowDebug((prev: boolean) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, []);
|
||||
|
||||
if (!showDebug) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg z-[100] font-mono text-sm">
|
||||
<div>Transitioning: {isTransitioning ? 'Yes' : 'No'}</div>
|
||||
<div>Body Overflow: {document.body.style.overflow}</div>
|
||||
<div>Scroll Position: {window.scrollY}</div>
|
||||
<div>View Height: {window.innerHeight}</div>
|
||||
<button
|
||||
onClick={() => document.body.style.overflow = ''}
|
||||
className="bg-blue-500 px-2 py-1 rounded mt-2"
|
||||
>
|
||||
Reset Overflow
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugOverlay;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
|
||||
<p className="text-white/70 mb-6">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trackFAQInteraction } from '../services/gtm';
|
||||
|
||||
export function FAQSection() {
|
||||
const { t } = useTranslation();
|
||||
const [openItem, setOpenItem] = useState<number | null>(null);
|
||||
|
||||
const faqs = t('faq.questions', { returnObjects: true }) as Array<{
|
||||
question: string;
|
||||
answer: string;
|
||||
category?: string;
|
||||
}>;
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
const isOpening = openItem !== index;
|
||||
const question = faqs[index]?.question || '';
|
||||
|
||||
// Track FAQ interaction
|
||||
trackFAQInteraction(question, isOpening ? 'open' : 'close');
|
||||
|
||||
setOpenItem(prev => prev === index ? null : index);
|
||||
};
|
||||
|
||||
const faqSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": faqs.map(faq => ({
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="faq-section py-16 px-4 max-w-4xl mx-auto">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
|
||||
<h2 className="text-3xl font-bold text-white mb-8 text-center">
|
||||
{t('faq.title')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="faq-item bg-zinc-800/50 border border-zinc-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-zinc-800/70 transition-colors"
|
||||
aria-expanded={openItem === index}
|
||||
aria-controls={`faq-answer-${index}`}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white pr-4">
|
||||
{faq.question}
|
||||
</h3>
|
||||
{openItem === index ? (
|
||||
<ChevronUp className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`faq-answer-${index}`}
|
||||
className={`overflow-hidden transition-all duration-300 ${
|
||||
openItem === index ? 'max-h-96' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-4 text-zinc-300 border-t border-zinc-700">
|
||||
<p>{faq.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-zinc-400">
|
||||
{t('faq.moreQuestions')}{' '}
|
||||
<a
|
||||
href="/contact"
|
||||
className="text-white hover:text-zinc-300 underline transition-colors"
|
||||
>
|
||||
{t('faq.contactUs')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export function FloatingPaths({ position }: { position: number }) {
|
||||
const paths = Array.from({ length: 36 }, (_, i) => ({
|
||||
id: i,
|
||||
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
|
||||
380 - i * 5 * position
|
||||
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
|
||||
152 - i * 5 * position
|
||||
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
|
||||
684 - i * 5 * position
|
||||
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
|
||||
color: `rgba(var(--accent),${0.1 + i * 0.01})`,
|
||||
width: 0.5 + i * 0.03,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none floating-paths">
|
||||
<svg className="w-full h-full text-accent" viewBox="0 0 696 316" fill="none" style={{ transform: 'translateZ(0)' }}>
|
||||
<title>Background Paths</title>
|
||||
{paths.map((path) => (
|
||||
<motion.path
|
||||
key={path.id}
|
||||
d={path.d}
|
||||
stroke="currentColor"
|
||||
strokeWidth={path.width}
|
||||
strokeOpacity={0.1 + path.id * 0.01}
|
||||
initial={{ pathLength: 0.3, opacity: 0.6 }}
|
||||
animate={{
|
||||
pathLength: 1,
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
pathOffset: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20 + Math.random() * 10,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
}}
|
||||
style={{ willChange: 'auto' }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// components/Footer.tsx
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mail, Linkedin, Github, MapPin } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative bg-zinc-800/50 backdrop-blur-sm border-t border-zinc-700/30 pt-8 pb-4 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/* Contact Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3">
|
||||
{t('footer.sections.contact.title')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`mailto:${t('footer.sections.contact.email')}`}
|
||||
className="group flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm !p-0 !min-h-0 !min-w-0 w-fit"
|
||||
aria-label={t('footer.sections.contact.aria.emailLink')}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors flex-shrink-0" size={16} />
|
||||
<span>{t('footer.sections.contact.email')}</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm w-fit">
|
||||
<MapPin className="h-4 w-4 text-zinc-500 flex-shrink-0" size={16} />
|
||||
<span>{t('footer.sections.contact.location')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('footer.sections.navigation.title')}
|
||||
</h3>
|
||||
<nav className="flex flex-col space-y-2">
|
||||
<Link
|
||||
to="/portfolio"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.portfolio')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.sections.navigation.links.contact')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Social Media Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('footer.sections.social.title')}
|
||||
</h3>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/damjan-savić-720288127/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
aria-label={t('footer.sections.social.aria.linkedin')}
|
||||
>
|
||||
<Linkedin className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/damjan1996"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
aria-label={t('footer.sections.social.aria.github')}
|
||||
>
|
||||
<Github className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Section */}
|
||||
<div className="border-t border-zinc-700/30 pt-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="text-zinc-400 text-xs text-left">
|
||||
© {currentYear} Damjan Savić. {t('footer.legal.copyright')}
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/imprint"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.imprint')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors !p-0 !min-h-0 !min-w-0"
|
||||
>
|
||||
{t('footer.legal.links.terms')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
|
||||
// Lazy load FloatingPaths - it's just decoration and shouldn't block initial render
|
||||
const FloatingPaths = lazy(() => import('./FloatingPaths').then(m => ({ default: m.FloatingPaths })));
|
||||
|
||||
const GlobalBackground = () => {
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [showPaths, setShowPaths] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrollPosition(window.scrollY * 0.001);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Delay loading FloatingPaths until after initial render
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowPaths(true), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background layer - behind everything */}
|
||||
<div
|
||||
className="fixed inset-0 bg-zinc-900"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
background: 'linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 50%, #1e1e1e 100%)',
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FloatingPaths layer - lazy loaded after 2s */}
|
||||
{showPaths && (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<FloatingPaths position={scrollPosition} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simple animated lines with CSS */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
style={{ opacity: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1="0%"
|
||||
y1="20%"
|
||||
x2="100%"
|
||||
y2="20%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
>
|
||||
<animate
|
||||
attributeName="y1"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y2"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="0%"
|
||||
y1="50%"
|
||||
x2="100%"
|
||||
y2="50%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.15"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="0%;100%;0%"
|
||||
dur="15s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="20%"
|
||||
y2="100%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.1"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x2"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalBackground;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function HreflangTags() {
|
||||
const { i18n } = useTranslation();
|
||||
const location = window.location;
|
||||
const currentPath = location.pathname;
|
||||
|
||||
const siteUrl = 'https://damjan-savic.com';
|
||||
const supportedLanguages = ['de', 'en', 'sr'];
|
||||
const defaultLanguage = 'de';
|
||||
|
||||
// Extract current language from URL
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
const currentLang = supportedLanguages.includes(pathSegments[0]) ? pathSegments[0] : defaultLanguage;
|
||||
|
||||
// Get path without language prefix
|
||||
const pathWithoutLang = currentLang === defaultLanguage
|
||||
? currentPath
|
||||
: currentPath.replace(`/${currentLang}`, '') || '/';
|
||||
|
||||
// Generate hreflang URLs
|
||||
const generateHreflangUrl = (lang: string) => {
|
||||
if (lang === defaultLanguage) {
|
||||
return `${siteUrl}${pathWithoutLang}`;
|
||||
}
|
||||
return `${siteUrl}/${lang}${pathWithoutLang}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Hreflang tags for all supported languages */}
|
||||
{supportedLanguages.map((lang) => (
|
||||
<link
|
||||
key={lang}
|
||||
rel="alternate"
|
||||
hrefLang={lang}
|
||||
href={generateHreflangUrl(lang)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* x-default hreflang */}
|
||||
<link
|
||||
rel="alternate"
|
||||
hrefLang="x-default"
|
||||
href={generateHreflangUrl(defaultLanguage)}
|
||||
/>
|
||||
|
||||
{/* Additional language meta tags */}
|
||||
<html lang={currentLang} />
|
||||
<meta property="og:locale" content={currentLang === 'de' ? 'de_DE' : currentLang === 'en' ? 'en_US' : 'sr_RS'} />
|
||||
|
||||
{/* Alternate og:locale tags */}
|
||||
{supportedLanguages
|
||||
.filter(lang => lang !== currentLang)
|
||||
.map(lang => (
|
||||
<meta
|
||||
key={`og-locale-${lang}`}
|
||||
property="og:locale:alternate"
|
||||
content={lang === 'de' ? 'de_DE' : lang === 'en' ? 'en_US' : 'sr_RS'}
|
||||
/>
|
||||
))}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
|
||||
declare module 'react' {
|
||||
interface ImgHTMLAttributes<T> extends React.HTMLAttributes<T> {
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageWithFallbackProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
|
||||
const ImageWithFallback: React.FC<ImageWithFallbackProps> = ({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
sizes = '100vw',
|
||||
loading = 'lazy',
|
||||
fetchpriority = 'auto'
|
||||
}) => {
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-gray-900 ${className}`}>
|
||||
<div className="text-center p-4">
|
||||
<ImageOff className="h-8 w-8 mx-auto mb-2 text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{alt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isLoading && (
|
||||
<div className={`absolute inset-0 bg-gray-900/50 animate-pulse ${className}`} />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${className} ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
|
||||
loading={loading}
|
||||
fetchpriority={fetchpriority}
|
||||
sizes={sizes}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => setError(true)}
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageWithFallback;
|
||||
@@ -1,25 +0,0 @@
|
||||
// Input.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
className={`w-full h-12 bg-zinc-900/50 border border-white/10 rounded-lg px-4 text-white
|
||||
placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary transition-all
|
||||
disabled:opacity-50 disabled:cursor-not-allowed ${error ? 'border-red-500' : ''} ${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -1,15 +0,0 @@
|
||||
// Label.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Label: React.FC<LabelProps> = ({ children, required, className = '', ...props }) => {
|
||||
return (
|
||||
<label className={`block text-sm font-medium text-white/80 mb-2 ${className}`} {...props}>
|
||||
{children}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'sr', name: 'Srpski' },
|
||||
];
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === i18n.language)?.name || 'Language';
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
<button
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white
|
||||
px-4 py-2 rounded-full transition-all duration-200
|
||||
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
||||
hover:scale-105 active:scale-95"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<Globe className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm">{currentLanguage}</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown with CSS transitions */}
|
||||
<div
|
||||
className={`absolute ${isMobile ? 'bottom-full mb-2' : 'top-full mt-2'} right-0 w-48 rounded-lg overflow-hidden
|
||||
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm shadow-lg
|
||||
transition-all duration-200 origin-top
|
||||
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
||||
role="listbox"
|
||||
aria-label="Languages"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="py-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||
hover:bg-zinc-800/50
|
||||
${i18n.language === lang.code
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
i18n.changeLanguage(lang.code);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={i18n.language === lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -1,274 +0,0 @@
|
||||
// Layout.tsx
|
||||
import { useEffect, useState, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
User,
|
||||
Briefcase,
|
||||
BookOpen,
|
||||
Phone,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
// Supabase wird lazy geladen um Initial Bundle zu reduzieren
|
||||
import { ContactInfo } from "./ContactInfo";
|
||||
import { NavLink } from "./NavLink";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import Footer from "./Footer";
|
||||
import { useScrollContext } from "./ScrollContext";
|
||||
import { PerformanceMonitor } from "./PerformanceMonitor";
|
||||
import GlobalBackground from "./GlobalBackground";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const location = useLocation();
|
||||
const { isMenuOpen, setIsMenuOpen } = useScrollContext();
|
||||
|
||||
// Initial mount with immediate execution
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to ensure smooth initial render
|
||||
requestAnimationFrame(() => {
|
||||
setIsMounted(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Schließe das Menü beim Routenwechsel
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [location.pathname, setIsMenuOpen]);
|
||||
|
||||
// Toggle Menu
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
|
||||
// Scroll-Handler
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 0);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Prüfe Admin-Status verzögert und lazy (nach Initial Render)
|
||||
useEffect(() => {
|
||||
const checkAdmin = async () => {
|
||||
// Dynamischer Import von Supabase - wird erst geladen wenn benötigt
|
||||
const { supabase } = await import("../utils/supabaseClient");
|
||||
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) {
|
||||
console.error("Error checking admin status:", error);
|
||||
return;
|
||||
}
|
||||
if (data.session) {
|
||||
const { user } = data.session;
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from("profiles")
|
||||
.select("isAdmin")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
if (adminError) {
|
||||
console.error("Error checking admin role:", adminError);
|
||||
return;
|
||||
}
|
||||
setIsAdmin(adminData.isAdmin);
|
||||
}
|
||||
};
|
||||
|
||||
// Verzögere Admin-Check deutlich (5s) um kritischen Pfad nicht zu blockieren
|
||||
const timeoutId = setTimeout(checkAdmin, 5000);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
path: "/",
|
||||
label: t("navigation.main.home"),
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
label: t("navigation.main.about"),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/portfolio",
|
||||
label: t("navigation.main.portfolio"),
|
||||
icon: <Briefcase className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/blog",
|
||||
label: t("navigation.main.blog"),
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: "/contact",
|
||||
label: t("navigation.main.contact"),
|
||||
icon: <Phone className="h-5 w-5" />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
path: "/dashboard",
|
||||
label: t("navigation.admin.dashboard"),
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Render with opacity 0 on initial mount to prevent flicker
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent">
|
||||
<GlobalBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col relative">
|
||||
{/* Global Background */}
|
||||
<GlobalBackground />
|
||||
|
||||
{/* Navigation – oberste Ebene */}
|
||||
<nav
|
||||
className={`
|
||||
fixed w-full z-40 transition-all duration-300
|
||||
bg-zinc-800/50 backdrop-blur-sm border-b border-zinc-700/30
|
||||
${scrolled
|
||||
? "bg-zinc-800/70 backdrop-blur-md shadow-lg shadow-zinc-900/30"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2 group shrink-0">
|
||||
<img
|
||||
src="/header-logo.svg"
|
||||
alt="Damjan Savić Logo"
|
||||
className="h-8 w-auto transition-transform duration-200 hover:scale-105"
|
||||
width={120}
|
||||
height={32}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
className="text-sm"
|
||||
/>
|
||||
))}
|
||||
<div className="ml-4 text-zinc-400 pl-2 border-l border-zinc-700">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden relative z-50 p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
onClick={toggleMenu}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu - CSS-only Animationen */}
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 md:hidden z-20 pointer-events-none transition-opacity duration-200 ${
|
||||
isMenuOpen ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed right-0 top-0 h-full w-80 bg-zinc-900/95 backdrop-blur-md shadow-xl md:hidden z-50 border-l border-zinc-800 transition-transform duration-300 ease-out ${
|
||||
isMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
{/* Sidebar Header mit Close-Button */}
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-800">
|
||||
<span className="text-white font-semibold">Menu</span>
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollbarer Sidebar-Inhalt */}
|
||||
<div className="h-[calc(100vh_-_4rem)] overflow-y-auto">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Contact Info */}
|
||||
<ContactInfo />
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="py-4 px-4">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="flex items-center w-full mb-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="px-4 py-3 border-t border-zinc-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Sprache ändern
|
||||
</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<main className="flex-1 pt-2 relative z-10 bg-transparent">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Performance Monitor */}
|
||||
<PerformanceMonitor />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface LazyComponentProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LazyComponent: React.FC<LazyComponentProps> = ({ children, fallback = <LoadingSpinner /> }) => {
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyComponent;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner = ({ size = 'md', className = '' }: LoadingSpinnerProps) => {
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
className={`relative ${sizes[size]}`}
|
||||
>
|
||||
{/* Base Triangle */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M50 10 L90 80 L10 80 Z"
|
||||
className="fill-none stroke-zinc-800"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Animated Triangle Segments */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<motion.path
|
||||
d="M50 10 L90 80"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M90 80 L10 80"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: 0.66
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M10 80 L50 10"
|
||||
className="stroke-white"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{
|
||||
pathLength: [0, 1, 0],
|
||||
opacity: [0.2, 1, 0.2]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: 1.33
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Optional inner gradient effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900/50 to-transparent rounded-full" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -1,28 +0,0 @@
|
||||
// src/components/MDXProvider.tsx
|
||||
import React from 'react';
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
|
||||
const components = {
|
||||
h1: (props: React.ComponentProps<'h1'>) => (
|
||||
<h1 className="text-3xl font-bold my-6" {...props} />
|
||||
),
|
||||
h2: (props: React.ComponentProps<'h2'>) => (
|
||||
<h2 className="text-2xl font-bold my-4" {...props} />
|
||||
),
|
||||
h3: (props: React.ComponentProps<'h3'>) => (
|
||||
<h3 className="text-xl font-bold my-3" {...props} />
|
||||
),
|
||||
p: (props: React.ComponentProps<'p'>) => (
|
||||
<p className="my-4 text-muted-foreground" {...props} />
|
||||
),
|
||||
code: (props: React.ComponentProps<'code'>) => (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props} />
|
||||
),
|
||||
pre: (props: React.ComponentProps<'pre'>) => (
|
||||
<pre className="bg-muted p-4 rounded-lg my-4 overflow-x-auto" {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
export function MDXLayout({ children }: { children: React.ReactNode }) {
|
||||
return <MDXProvider components={components}>{children}</MDXProvider>;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// components/NavLink.tsx
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
|
||||
interface NavLinkProps {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavLink({ to, icon, label, onClick, className }: NavLinkProps) {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname === to
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||
transition-all duration-200 ease-in-out
|
||||
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-sm tracking-wide">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// components/PageContainer.tsx
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageContainer = ({ children }: PageContainerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Suspense fallback={null}>
|
||||
<main className="flex-1 pt-16">
|
||||
{children}
|
||||
</main>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer;
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Simplified page transition - no loading delay for better LCP
|
||||
const PageTransition = ({ children }: PageTransitionProps) => {
|
||||
const location = useLocation()
|
||||
const previousPathRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only scroll on path change, not on initial mount
|
||||
if (previousPathRef.current && location.pathname !== previousPathRef.current) {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
previousPathRef.current = location.pathname
|
||||
}, [location.pathname])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default PageTransition
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Activity, TrendingUp, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface WebVitalMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: 'good' | 'needs-improvement' | 'poor';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
lcp: WebVitalMetric[];
|
||||
inp: WebVitalMetric[];
|
||||
cls: WebVitalMetric[];
|
||||
fcp: WebVitalMetric[];
|
||||
ttfb: WebVitalMetric[];
|
||||
}
|
||||
|
||||
const THRESHOLDS = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
FCP: { good: 1800, poor: 3000 },
|
||||
TTFB: { good: 800, poor: 1800 }
|
||||
};
|
||||
|
||||
export function PerformanceMonitor() {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
lcp: [],
|
||||
inp: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: []
|
||||
});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for performance metrics from webVitals
|
||||
const handlePerformanceData = (event: CustomEvent) => {
|
||||
const metric = event.detail as WebVitalMetric;
|
||||
setMetrics(prev => ({
|
||||
...prev,
|
||||
[metric.name.toLowerCase()]: [...(prev[metric.name.toLowerCase() as keyof PerformanceMetrics] || []), metric].slice(-10)
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('web-vitals', handlePerformanceData as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('web-vitals', handlePerformanceData as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getLatestMetric = (metricName: keyof PerformanceMetrics) => {
|
||||
const metricArray = metrics[metricName];
|
||||
return metricArray[metricArray.length - 1];
|
||||
};
|
||||
|
||||
const getRatingColor = (rating?: string) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-500';
|
||||
case 'needs-improvement': return 'text-yellow-500';
|
||||
case 'poor': return 'text-red-500';
|
||||
default: return 'text-zinc-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating?: string) => {
|
||||
switch (rating) {
|
||||
case 'good': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'needs-improvement': return <AlertCircle className="h-4 w-4" />;
|
||||
case 'poor': return <AlertCircle className="h-4 w-4" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (name: string, value?: number) => {
|
||||
if (value === undefined) return '-';
|
||||
if (name === 'CLS') return value.toFixed(3);
|
||||
return `${Math.round(value)}ms`;
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const latestLCP = getLatestMetric('lcp');
|
||||
const latestINP = getLatestMetric('inp');
|
||||
const latestCLS = getLatestMetric('cls');
|
||||
|
||||
if (!latestLCP || !latestINP || !latestCLS) return null;
|
||||
|
||||
let score = 100;
|
||||
|
||||
// LCP scoring
|
||||
if (latestLCP.value > THRESHOLDS.LCP.poor) score -= 33;
|
||||
else if (latestLCP.value > THRESHOLDS.LCP.good) score -= 16;
|
||||
|
||||
// INP scoring
|
||||
if (latestINP.value > THRESHOLDS.INP.poor) score -= 33;
|
||||
else if (latestINP.value > THRESHOLDS.INP.good) score -= 16;
|
||||
|
||||
// CLS scoring
|
||||
if (latestCLS.value > THRESHOLDS.CLS.poor) score -= 34;
|
||||
else if (latestCLS.value > THRESHOLDS.CLS.good) score -= 18;
|
||||
|
||||
return Math.max(0, Math.round(score));
|
||||
};
|
||||
|
||||
// Only show in development or with special flag
|
||||
if (process.env.NODE_ENV === 'production' && !window.location.search.includes('debug=true')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="fixed bottom-4 right-4 z-50 p-3 bg-zinc-800 border border-zinc-700 rounded-full shadow-lg hover:bg-zinc-700 transition-all"
|
||||
aria-label="Toggle Performance Monitor"
|
||||
>
|
||||
<Activity className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Performance Panel */}
|
||||
{isVisible && (
|
||||
<div className="fixed bottom-20 right-4 z-50 w-80 bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Core Web Vitals
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overall Score */}
|
||||
{calculateScore() !== null && (
|
||||
<div className="mb-4 p-3 bg-zinc-800 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">Performance Score</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
calculateScore()! >= 90 ? 'text-green-500' :
|
||||
calculateScore()! >= 50 ? 'text-yellow-500' :
|
||||
'text-red-500'
|
||||
}`}>
|
||||
{calculateScore()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'lcp', label: 'Largest Contentful Paint', threshold: THRESHOLDS.LCP },
|
||||
{ key: 'inp', label: 'Interaction to Next Paint', threshold: THRESHOLDS.INP },
|
||||
{ key: 'cls', label: 'Cumulative Layout Shift', threshold: THRESHOLDS.CLS },
|
||||
{ key: 'fcp', label: 'First Contentful Paint', threshold: THRESHOLDS.FCP },
|
||||
{ key: 'ttfb', label: 'Time to First Byte', threshold: THRESHOLDS.TTFB }
|
||||
].map(({ key, label }) => {
|
||||
const latest = getLatestMetric(key as keyof PerformanceMetrics);
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between p-2 bg-zinc-800/50 rounded">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-300">{label}</div>
|
||||
<div className="text-xs text-zinc-500 uppercase">{key}</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${getRatingColor(latest?.rating)}`}>
|
||||
{getRatingIcon(latest?.rating)}
|
||||
<span className="font-mono text-sm">
|
||||
{formatValue(key.toUpperCase(), latest?.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Real user metrics • Updates live •
|
||||
<a
|
||||
href="https://web.dev/vitals/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface NavigationItem {
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PostNavigationProps {
|
||||
previous?: NavigationItem;
|
||||
next?: NavigationItem;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
const PostNavigation: React.FC<PostNavigationProps> = ({ previous, next, basePath }) => {
|
||||
if (!previous && !next) return null;
|
||||
|
||||
return (
|
||||
<nav className="border-t border-white/10 mt-12 pt-8">
|
||||
<div className="flex justify-between">
|
||||
{previous ? (
|
||||
<Link
|
||||
to={`${basePath}/${previous.slug}`}
|
||||
className="group flex items-center text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2 transition-transform group-hover:-translate-x-1" />
|
||||
<div>
|
||||
<div className="text-sm text-white/40">Previous</div>
|
||||
<div className="line-clamp-1">{previous.title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{next ? (
|
||||
<Link
|
||||
to={`${basePath}/${next.slug}`}
|
||||
className="group flex items-center text-right text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-white/40">Next</div>
|
||||
<div className="line-clamp-1">{next.title}</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostNavigation
|
||||
@@ -1,171 +0,0 @@
|
||||
// components/ProjectContentTemplate.tsx
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Building2, Clock, Tag } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
points?: string[];
|
||||
code?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface ProjectTranslation {
|
||||
meta: {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
client: string;
|
||||
duration: string;
|
||||
technologies: string[];
|
||||
};
|
||||
content: {
|
||||
intro: string;
|
||||
challenge: Section;
|
||||
solution: Section;
|
||||
technical: Section;
|
||||
implementation: Section;
|
||||
results: Section;
|
||||
conclusion: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectContentTemplateProps {
|
||||
translationKey: string;
|
||||
fallbackData?: ProjectTranslation;
|
||||
}
|
||||
|
||||
const ProjectContentTemplate: React.FC<ProjectContentTemplateProps> = ({
|
||||
translationKey,
|
||||
fallbackData
|
||||
}) => {
|
||||
// Nur 't' aus dem Hook extrahieren, da 'i18n' nicht verwendet wird
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Übersetzten Inhalt abrufen
|
||||
const translatedContent = t(`${translationKey}`, {
|
||||
returnObjects: true,
|
||||
defaultValue: fallbackData
|
||||
}) as ProjectTranslation;
|
||||
|
||||
const renderSection = (section: Section) => {
|
||||
if (!section) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.description && (
|
||||
<p className="text-zinc-400 mb-6">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
{section.content && (
|
||||
<div className="prose prose-invert max-w-none mb-6">
|
||||
{section.content}
|
||||
</div>
|
||||
)}
|
||||
{section.points && (
|
||||
<ul className="list-disc list-inside text-zinc-300 space-y-2">
|
||||
{section.points.map((point, index) => (
|
||||
<li key={index} className="ml-4">
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{section.code && (
|
||||
<pre className="bg-zinc-800/50 p-4 rounded-lg overflow-x-auto border border-zinc-800">
|
||||
<code className="text-zinc-300">
|
||||
{section.code}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
{section.image && (
|
||||
<div className="mt-6">
|
||||
<img
|
||||
src={section.image}
|
||||
alt={section.title}
|
||||
className="rounded-lg w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="max-w-4xl mx-auto">
|
||||
{/* Project Meta Information */}
|
||||
<div className="mb-12">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6 text-zinc-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<time>{translatedContent.meta.date}</time>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{translatedContent.meta.client}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{translatedContent.meta.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||
{translatedContent.meta.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-zinc-400">
|
||||
{translatedContent.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Introduction */}
|
||||
<div className="prose prose-invert max-w-none mb-12">
|
||||
<p>{translatedContent.content.intro}</p>
|
||||
</div>
|
||||
|
||||
{/* Main Sections */}
|
||||
{renderSection(translatedContent.content.challenge)}
|
||||
{renderSection(translatedContent.content.solution)}
|
||||
{renderSection(translatedContent.content.technical)}
|
||||
{renderSection(translatedContent.content.implementation)}
|
||||
{renderSection(translatedContent.content.results)}
|
||||
|
||||
{/* Conclusion */}
|
||||
{translatedContent.content.conclusion && (
|
||||
<div className="prose prose-invert max-w-none mb-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
{t('portfolio.sections.conclusion')}
|
||||
</h2>
|
||||
<p>{translatedContent.content.conclusion}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technologies */}
|
||||
<div className="mt-12 pt-6 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<Tag className="h-4 w-4 text-zinc-400" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{translatedContent.meta.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
|
||||
>
|
||||
{t(`technologies.${tech}`, tech)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectContentTemplate;
|
||||
@@ -1,161 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Github, Globe, Zap, TrendingUp, Users } from 'lucide-react';
|
||||
import { ProjectSchema } from './schemas/ProjectSchema';
|
||||
|
||||
interface ProjectShowcaseProps {
|
||||
project: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
technicalChallenge: string;
|
||||
businessImpact: {
|
||||
roi?: string;
|
||||
performanceGain?: string;
|
||||
usersImpacted?: string;
|
||||
};
|
||||
technologies: string[];
|
||||
category: string;
|
||||
dateCreated: string;
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
screenshotUrl?: string;
|
||||
features: string[];
|
||||
codeExample?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProjectShowcase({ project }: ProjectShowcaseProps) {
|
||||
return (
|
||||
<article className="project-showcase bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ProjectSchema project={project} />
|
||||
|
||||
{/* Hero Section with Screenshot */}
|
||||
{project.screenshotUrl && (
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={project.screenshotUrl}
|
||||
alt={`Screenshot of ${project.title}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Title and Description */}
|
||||
<header>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">{project.title}</h2>
|
||||
<p className="text-lg text-zinc-300 leading-relaxed">{project.description}</p>
|
||||
</header>
|
||||
|
||||
{/* For Recruiters: Technical Challenge */}
|
||||
<section className="technical-challenge">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="h-5 w-5 text-blue-500" />
|
||||
<h3 className="text-xl font-semibold text-white">Technical Challenge</h3>
|
||||
</div>
|
||||
<p className="text-zinc-300 bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
{project.technicalChallenge}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* For Clients: Business Impact */}
|
||||
<section className="business-impact">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
<h3 className="text-xl font-semibold text-white">Business Impact</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{project.businessImpact.roi && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">ROI</p>
|
||||
<p className="text-2xl font-bold text-green-400">{project.businessImpact.roi}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.businessImpact.performanceGain && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">Performance</p>
|
||||
<p className="text-2xl font-bold text-blue-400">{project.businessImpact.performanceGain}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.businessImpact.usersImpacted && (
|
||||
<div className="bg-zinc-800/50 p-4 rounded-lg border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400 mb-1">Users Impacted</p>
|
||||
<p className="text-2xl font-bold text-purple-400">{project.businessImpact.usersImpacted}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technology Stack with Keywords */}
|
||||
<section className="tech-stack">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Technologies Used</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.technologies.map(tech => (
|
||||
<span
|
||||
key={tech}
|
||||
className="tech-tag px-3 py-1 bg-zinc-800 border border-zinc-700 text-zinc-300 rounded-full text-sm hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Features */}
|
||||
{project.features && project.features.length > 0 && (
|
||||
<section className="features">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Key Features</h3>
|
||||
<ul className="space-y-2">
|
||||
{project.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-0.5">•</span>
|
||||
<span className="text-zinc-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Code Quality Showcase */}
|
||||
{project.codeExample && (
|
||||
<section className="code-showcase">
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Code Example</h3>
|
||||
<pre className="bg-zinc-950 p-4 rounded-lg overflow-x-auto border border-zinc-800">
|
||||
<code className="text-sm text-zinc-300 font-mono">
|
||||
{project.codeExample}
|
||||
</code>
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="project-links flex gap-4 pt-4">
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
View Live Project
|
||||
</a>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
interface ResponsiveImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
|
||||
export default function ResponsiveImage({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
sizes = '100vw',
|
||||
loading = 'lazy',
|
||||
fetchpriority = 'auto'
|
||||
}: ResponsiveImageProps) {
|
||||
// Generate Unsplash responsive URLs
|
||||
const generateSrcSet = (url: string) => {
|
||||
if (!url.includes('unsplash.com')) return undefined;
|
||||
|
||||
const widths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
|
||||
return widths
|
||||
.map((w) => {
|
||||
const modifiedUrl = url.replace(/w=\d+/, `w=${w}`);
|
||||
return `${modifiedUrl} ${w}w`;
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const srcSet = generateSrcSet(src);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={loading}
|
||||
fetchpriority={fetchpriority}
|
||||
sizes={sizes}
|
||||
srcSet={srcSet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Home, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
const error = useRouteError();
|
||||
const navigate = useNavigate();
|
||||
|
||||
let title = 'Something went wrong';
|
||||
let message = 'An unexpected error occurred';
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 404) {
|
||||
title = 'Page not found';
|
||||
message = "The page you're looking for doesn't exist or has been moved.";
|
||||
} else {
|
||||
title = `Error ${error.status}`;
|
||||
message = error.statusText || 'An error occurred while processing your request.';
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
message = error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-black">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-4">{title}</h1>
|
||||
<p className="text-white/70 mb-8">{message}</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Home Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteErrorBoundary;
|
||||
@@ -1,183 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HreflangTags } from './HreflangTags';
|
||||
import { seoContent as seoContentDe } from '../i18n/locales/de/seo-content';
|
||||
import { seoContent as seoContentEn } from '../i18n/locales/en/seo-content';
|
||||
import { seoContent as seoContentSr } from '../i18n/locales/sr/seo-content';
|
||||
import { meta as metaDe } from '../i18n/locales/de/meta';
|
||||
import { meta as metaEn } from '../i18n/locales/en/meta';
|
||||
import { meta as metaSr } from '../i18n/locales/sr/meta';
|
||||
import { AutoBreadcrumbs } from './schemas/BreadcrumbSchema';
|
||||
|
||||
interface SEOProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
article?: boolean;
|
||||
schema?: object;
|
||||
keywords?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
const SEO: React.FC<SEOProps> = ({
|
||||
title,
|
||||
description,
|
||||
image = '/portrait.jpg',
|
||||
article = false,
|
||||
schema,
|
||||
keywords,
|
||||
author = 'Damjan Savić',
|
||||
}) => {
|
||||
// Nur i18n wird benötigt – t wurde entfernt, da es nicht genutzt wird.
|
||||
const { i18n } = useTranslation();
|
||||
const siteTitle = 'Damjan Savić';
|
||||
const fullTitle = title ? `${title} | ${siteTitle}` : siteTitle;
|
||||
const siteUrl = 'https://damjan-savic.com';
|
||||
const currentUrl = typeof window !== 'undefined' ? window.location.href : siteUrl;
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
// SEO-Content basierend auf Sprache auswählen
|
||||
const seoContent = currentLanguage === 'de' ? seoContentDe :
|
||||
currentLanguage === 'sr' ? seoContentSr :
|
||||
seoContentEn;
|
||||
const meta = currentLanguage === 'de' ? metaDe :
|
||||
currentLanguage === 'sr' ? metaSr :
|
||||
metaEn;
|
||||
const defaultDescription = description || seoContent.hero.description;
|
||||
const defaultKeywords = keywords || meta.site.keywords;
|
||||
|
||||
// Default schema für die Website
|
||||
const defaultSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: 'Damjan Savić',
|
||||
alternateName: 'Damjan 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