Files
Portfolio/src/pages-vite/portfolio/components/PortfolioCard.tsx
T
damjan_savicandClaude Opus 4.5 b1ec7b4d61 Migrate from Vite to Next.js 15 with SSR
- Replace Vite + React Router with Next.js 15 App Router
- Implement i18n with next-intl (URL-based: /de, /en, /sr)
- Add SSR/SSG for all pages (48 static pages generated)
- Setup Supabase SSR client for auth
- Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms
- Add Docker support with standalone output
- Replace i18next with next-intl JSON translations
- Use next/image for optimized images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 01:00:33 +01:00

169 lines
7.5 KiB
TypeScript

import React from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ExternalLink, Calendar, Tag } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trackProjectClick } from '../../../services/gtm';
interface Project {
id: string;
title: string;
description: string;
tags: string[];
slug: string;
date?: string;
excerpt?: string;
technologies?: string[];
category?: string;
}
interface PortfolioCardProps {
project: Project;
}
const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
const { t } = useTranslation();
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
const basePath = `/images/projects/${project.slug}`;
const imagePath = `${basePath}/cover.jpg`;
const srcSetJpg = `${basePath}/cover-200w.jpg 200w, ${basePath}/cover-300w.jpg 300w, ${basePath}/cover-400w.jpg 400w, ${basePath}/cover-600w.jpg 600w, ${basePath}/cover-800w.jpg 800w, ${basePath}/cover-1200w.jpg 1200w`;
const srcSetWebP = `${basePath}/cover-200w.webp 200w, ${basePath}/cover-300w.webp 300w, ${basePath}/cover-400w.webp 400w, ${basePath}/cover-600w.webp 600w, ${basePath}/cover-800w.webp 800w, ${basePath}/cover-1200w.webp 1200w`;
// Mobile: ~350px, Tablet: ~400px, Desktop 2-col: ~550px, Desktop 3-col: ~400px
const imageSizes = "(max-width: 640px) 350px, (max-width: 768px) 400px, (max-width: 1024px) 550px, 400px";
// Verwende technologies anstelle von tags wenn verfügbar
const displayTags = project.technologies || project.tags;
const handleClick = () => {
trackProjectClick(project.title, project.category);
};
return (
<Link
to={`/portfolio/${project.slug}`}
onClick={handleClick}
className="group relative block bg-zinc-900/50 backdrop-blur-sm
rounded-xl shadow-lg hover:shadow-2xl
ring-1 ring-zinc-800 hover:ring-zinc-700
transition-all duration-500 focus:outline-none focus:ring-2
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
transform hover:-translate-y-1"
>
<motion.div
variants={itemVariants}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-xl"
style={{
transform: 'translateZ(0)',
willChange: 'opacity, transform'
}}
>
{/* Image Container */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<picture>
<source
srcSet={srcSetWebP}
sizes={imageSizes}
type="image/webp"
/>
<source
srcSet={srcSetJpg}
sizes={imageSizes}
type="image/jpeg"
/>
<img
src={imagePath}
alt={project.title}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
group-hover:scale-110"
loading="lazy"
width={400}
height={225}
decoding="async"
style={{ transformOrigin: 'center center' }}
/>
</picture>
</div>
{/* Content - with relative positioning to overlap the gradient */}
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
{/* Project Info */}
<div className="flex items-center gap-4 mb-4 text-zinc-500">
{project.date && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>{project.date}</span>
</div>
)}
{displayTags && displayTags.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" />
<span>
{t('portfolio.ui.technologiesCount', {
count: displayTags.length,
defaultValue: '{{count}} Technologies'
})}
</span>
</div>
)}
</div>
{/* Title & Description */}
<h3 className="text-xl font-semibold text-white mb-3
group-hover:text-zinc-100 transition-colors duration-300">
{project.title}
</h3>
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
group-hover:text-zinc-300 transition-colors duration-300">
{project.excerpt || project.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{displayTags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-zinc-800/50
text-sm text-zinc-400 rounded-full
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
transition-all duration-300"
>
{tag}
</span>
))}
{displayTags.length > 3 && (
<span className="text-sm text-zinc-600">
{t('portfolio.ui.moreTechnologies', {
count: displayTags.length - 3,
defaultValue: '+{{count}} more'
})}
</span>
)}
</div>
{/* Link Icon */}
<div className="absolute top-6 right-6 p-2.5 rounded-full
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
opacity-0 group-hover:opacity-100
transform translate-y-2 group-hover:translate-y-0
transition-all duration-300">
<ExternalLink className="h-4 w-4 text-zinc-400" />
</div>
</div>
{/* Subtle Hover Border */}
<div className="absolute inset-0 rounded-xl pointer-events-none
ring-1 ring-zinc-600/0 group-hover:ring-zinc-600/30
transition-all duration-500" />
</motion.div>
</Link>
);
};
export default PortfolioCard;