- 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>
169 lines
7.5 KiB
TypeScript
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; |