Merge origin/master and resolve conflicts

This commit is contained in:
2026-01-25 19:30:43 +01:00
186 changed files with 66422 additions and 23608 deletions
+26 -11
View File
@@ -111,7 +111,7 @@ export function ContactForm() {
<div className="relative w-24 h-24 rounded-full overflow-hidden border-2 border-zinc-700 flex-shrink-0">
<Image
src="/images/headshot.webp"
alt="Damjan Savić"
alt="Damjan Savić - Full-Stack Web Developer"
fill
sizes="96px"
className="object-cover"
@@ -135,7 +135,7 @@ export function ContactForm() {
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<MapPin className="h-6 w-6 text-zinc-400" />
<MapPin className="h-6 w-6 text-zinc-400" aria-hidden="true" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
@@ -154,7 +154,7 @@ export function ContactForm() {
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<Phone className="h-6 w-6 text-zinc-400" />
<Phone className="h-6 w-6 text-zinc-400" aria-hidden="true" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
@@ -176,7 +176,7 @@ export function ContactForm() {
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<Mail className="h-6 w-6 text-zinc-400" />
<Mail className="h-6 w-6 text-zinc-400" aria-hidden="true" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
@@ -210,7 +210,7 @@ export function ContactForm() {
>
<div className="bg-zinc-800/30 border border-zinc-800 rounded-xl p-6 md:p-8">
<div className="flex items-center gap-2 mb-2">
<Mail className="h-5 w-5 text-zinc-400" />
<Mail className="h-5 w-5 text-zinc-400" aria-hidden="true" />
<h2 className="text-xl font-semibold text-white">
{t('contactForm.title')}
</h2>
@@ -233,10 +233,13 @@ export function ContactForm() {
onChange={handleChange}
placeholder={t('contactForm.name.placeholder')}
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
/>
{errors.name && (
<p className="text-sm text-red-400">{errors.name}</p>
<p id="name-error" role="alert" className="text-sm text-red-400">{errors.name}</p>
)}
</div>
@@ -252,10 +255,13 @@ export function ContactForm() {
onChange={handleChange}
placeholder={t('contactForm.email.placeholder')}
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
/>
{errors.email && (
<p className="text-sm text-red-400">{errors.email}</p>
<p id="email-error" role="alert" className="text-sm text-red-400">{errors.email}</p>
)}
</div>
@@ -271,10 +277,13 @@ export function ContactForm() {
placeholder={t('contactForm.message.placeholder')}
rows={6}
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error' : undefined}
className="w-full px-4 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50 resize-none"
/>
{errors.message && (
<p className="text-sm text-red-400">{errors.message}</p>
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
)}
</div>
</div>
@@ -283,9 +292,11 @@ export function ContactForm() {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-live="polite"
className="flex items-center gap-2 p-3 bg-green-900/20 border border-green-900/50 rounded-lg"
>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
<p className="text-green-400 text-sm">
{t('contactForm.successMessage')}
</p>
@@ -296,6 +307,8 @@ export function ContactForm() {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-live="assertive"
className="p-3 bg-red-900/20 border border-red-900/50 rounded-lg"
>
<p className="text-red-400 text-sm">
@@ -307,13 +320,15 @@ export function ContactForm() {
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
aria-label={isSubmitting ? t('contactForm.submitting') : undefined}
className="w-full h-12 bg-zinc-100 hover:bg-white text-zinc-900 font-medium rounded-lg transition-colors duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
) : (
<>
<Send className="h-5 w-5" />
<Send className="h-5 w-5" aria-hidden="true" />
{t('contactForm.submit')}
</>
)}
+1 -1
View File
@@ -26,7 +26,7 @@ const Hero = async () => {
<div className="md:hidden absolute inset-x-0 bottom-0 h-[60%] bg-gradient-to-t from-zinc-950 via-zinc-950/80 via-40% to-transparent z-10 pointer-events-none" />
{/* Social Links - Top */}
<div className="absolute top-8 left-6 sm:left-12 lg:left-20 flex gap-4 z-20">
<div className="absolute top-8 left-6 sm:left-12 lg:left-20 flex gap-4 z-30">
<a
href="https://www.linkedin.com/in/damjan-savi%C4%87-720288127/"
target="_blank"
+60 -55
View File
@@ -2,15 +2,54 @@
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { Database, Server, Code2, Bot, Workflow, FileCode2 } from 'lucide-react';
interface Skill {
name: string;
level: number;
description: string;
}
interface SkeletonProps {
className?: string;
}
const Skeleton: React.FC<SkeletonProps> = ({ className }) => (
<div className={`animate-pulse bg-zinc-700/50 rounded ${className}`}></div>
);
const SkillsSkeleton = () => (
<section id="skills" className="py-24 relative" aria-label="Loading skills" aria-busy="true">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Skeleton className="h-8 w-48 mb-12" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Skills Progress Bars Skeleton */}
<div className="space-y-6">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</div>
{/* Skills Icons Grid Skeleton */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm">
<Skeleton className="w-8 h-8 mx-auto mb-3" />
<Skeleton className="h-4 w-16 mx-auto" />
</div>
))}
</div>
</div>
</div>
</section>
);
const iconMap: Record<string, React.ReactNode> = {
'AI & LLMs': <Bot className="w-8 h-8" />,
'Automation': <Workflow className="w-8 h-8" />,
@@ -25,23 +64,27 @@ const iconMap: Record<string, React.ReactNode> = {
const Skills = () => {
const t = useTranslations('pages.home.skills');
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
useEffect(() => {
try {
const translatedSkills = t.raw('skills') as Skill[];
if (Array.isArray(translatedSkills)) {
setSkills(translatedSkills);
// Temporary delay to see skeleton loading state
const timer = setTimeout(() => {
try {
const translatedSkills = t.raw('skills') as Skill[];
if (Array.isArray(translatedSkills)) {
setSkills(translatedSkills);
}
} catch (error) {
console.error('Error loading skills:', error);
setSkills([]);
}
} catch (error) {
console.error('Error loading skills:', error);
setSkills([]);
}
}, 2000);
return () => clearTimeout(timer);
}, [t]);
if (skills.length === 0) {
return <div className="py-24 text-center text-zinc-400">Loading skills...</div>;
return <SkillsSkeleton />;
}
return (
@@ -96,54 +139,16 @@ const Skills = () => {
{skills.map((skill, idx) => (
<motion.div
key={skill.name}
className="relative"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: idx * 0.1 }}
className="p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3"
>
<motion.div
className={`relative p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors duration-300 hover:bg-zinc-700/50 ${
selectedSkill === skill.name ? 'ring-2 ring-zinc-500 z-20' : ''
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
onHoverStart={() => setSelectedSkill(skill.name)}
onHoverEnd={() => setSelectedSkill(null)}
role="button"
>
<motion.div
className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}
animate={{
rotate: selectedSkill === skill.name ? [0, -10, 10, 0] : 0,
scale: selectedSkill === skill.name ? 1.1 : 1
}}
transition={{ duration: 0.4 }}
>
{iconMap[skill.name] || <Code2 className="w-8 h-8" />}
</motion.div>
<span className="text-white text-sm text-center">{skill.name}</span>
<AnimatePresence>
{selectedSkill === skill.name && (
<motion.div
className="absolute left-1/2 top-0 -translate-x-1/2 bg-zinc-800/80 backdrop-blur-sm rounded-lg px-4 py-2 text-zinc-300 text-xs text-center shadow-xl pointer-events-none"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.15 }}
style={{
whiteSpace: 'nowrap',
transform: 'translate(-50%, calc(-100% - 12px))',
zIndex: 999
}}
>
{skill.description}
<div className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-800" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
<div className="text-white">
{iconMap[skill.name] || <Code2 className="w-8 h-8" />}
</div>
<span className="text-white text-sm text-center">{skill.name}</span>
</motion.div>
))}
</div>