Spaces:
Sleeping
Sleeping
| import React, { useRef } from 'react'; | |
| import { Link } from 'react-router-dom'; | |
| import ImageFlex from './ImageFlex.jsx'; | |
| export default function ProjectCard({ project }) { | |
| const cardRef = useRef(null); | |
| function getStatusClasses(status) { | |
| if (!status) return 'bg-slate-100 text-slate-700'; | |
| switch (status) { | |
| case 'Completed': | |
| return 'bg-emerald-100 text-emerald-700'; | |
| case 'Ongoing': | |
| return 'bg-amber-100 text-amber-700'; | |
| case 'Upcoming': | |
| return 'bg-sky-100 text-sky-700'; | |
| default: | |
| return 'bg-slate-100 text-slate-700'; | |
| } | |
| } | |
| function onMove(e) { | |
| const el = cardRef.current; | |
| if (!el) return; | |
| if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; | |
| const rect = el.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const midX = rect.width / 2; | |
| const midY = rect.height / 2; | |
| const rotateX = ((y - midY) / midY) * -4; | |
| const rotateY = ((x - midX) / midX) * 4; | |
| el.style.transform = `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; | |
| } | |
| function onLeave() { | |
| const el = cardRef.current; | |
| if (!el) return; | |
| el.style.transform = 'perspective(800px) rotateX(0deg) rotateY(0deg)'; | |
| } | |
| const hasGallery = Array.isArray(project.gallery) && project.gallery.length > 0; | |
| const primaryImage = | |
| (typeof project.image === 'string' && project.image.replace(/\.(jpg|jpeg|png|webp)$/i, '')) || null; | |
| const galleryImage = hasGallery ? project.gallery[0] : null; | |
| const explicitBase = | |
| typeof project.imageBase === 'string' | |
| ? project.imageBase.replace(/\.(jpg|jpeg|png|webp)$/i, '') | |
| : null; | |
| const slugFallback = !project.noDetail && project.slug ? `/assets/projects/${project.slug}/gallery-1` : null; | |
| const imageBases = [ | |
| primaryImage, | |
| galleryImage, | |
| explicitBase, | |
| slugFallback | |
| ].filter(Boolean); | |
| const hasImage = imageBases.length > 0; | |
| return ( | |
| <article | |
| ref={cardRef} | |
| onMouseMove={onMove} | |
| onMouseLeave={onLeave} | |
| className={`relative card group overflow-hidden will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:bg-brand-600/0 before:blur before:transition before:duration-300 hover:before:bg-brand-600/10 ${ | |
| hasImage ? 'aspect-[3/4] min-h-[400px] md:min-h-[500px]' : 'h-full' | |
| }`} | |
| aria-labelledby={`proj-${project.slug}`} | |
| > | |
| {hasImage ? ( | |
| <> | |
| <div className="relative h-full w-full bg-slate-200"> | |
| <ImageFlex | |
| base={imageBases[0]} | |
| srcCandidates={imageBases.flatMap((base) => | |
| ['webp', 'jpg', 'jpeg', 'png'].map((ext) => `${base}.${ext}`) | |
| )} | |
| alt={project.title} | |
| className="h-full w-full object-contain transition-transform group-hover:scale-[1.03]" | |
| loading="lazy" | |
| /> | |
| <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/80 via-slate-900/40 to-transparent"></div> | |
| </div> | |
| {/* Overlay text content */} | |
| <div className="absolute bottom-0 left-0 right-0 p-5 text-white"> | |
| <h3 id={`proj-${project.slug}`} className="h3 text-white"> | |
| {project.noDetail ? ( | |
| <span>{project.title}</span> | |
| ) : ( | |
| <Link to={`/project/${project.slug}`} className="hover:underline"> | |
| {project.title} | |
| </Link> | |
| )} | |
| </h3> | |
| <p className="mt-1 text-sm text-white/90">{project.location}</p> | |
| {project.projectSize && ( | |
| <p className="mt-1 text-sm font-medium text-white"> | |
| {project.projectSize} | |
| </p> | |
| )} | |
| <div className="mt-4 flex items-center justify-between"> | |
| <span className="flex flex-wrap items-center gap-2"> | |
| {project.status && ( | |
| <span | |
| className={`rounded px-2 py-1 text-xs font-medium ${getStatusClasses(project.status)}`} | |
| aria-label={`Status: ${project.status}`} | |
| > | |
| {project.status} | |
| </span> | |
| )} | |
| {(project.categories || []).slice(0, 2).map((c) => ( | |
| <span key={c} className="rounded bg-white/20 backdrop-blur-sm px-2 py-1 text-xs text-white"> | |
| {c} | |
| </span> | |
| ))} | |
| </span> | |
| {!project.noDetail && ( | |
| <Link | |
| to={`/project/${project.slug}`} | |
| className="text-sm font-medium text-white underline-offset-2 hover:underline" | |
| aria-label={`View ${project.title}`} | |
| > | |
| View details | |
| </Link> | |
| )} | |
| </div> | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="flex h-24 w-full items-center justify-center bg-gradient-to-br from-brand-50 via-white to-brand-100"> | |
| <svg | |
| className="h-12 w-12 text-brand-600" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.5" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| aria-hidden="true" | |
| > | |
| <rect x="3" y="3" width="7" height="7" /> | |
| <rect x="14" y="3" width="7" height="7" /> | |
| <rect x="14" y="14" width="7" height="7" /> | |
| <rect x="3" y="14" width="7" height="7" /> | |
| </svg> | |
| </div> | |
| <div className="p-5"> | |
| <h3 id={`proj-${project.slug}`} className="h3"> | |
| {project.noDetail ? ( | |
| <span>{project.title}</span> | |
| ) : ( | |
| <Link to={`/project/${project.slug}`} className="hover:underline"> | |
| {project.title} | |
| </Link> | |
| )} | |
| </h3> | |
| <p className="mt-1 text-sm text-slate-600">{project.location}</p> | |
| {project.projectSize && ( | |
| <p className="mt-1 text-sm font-medium text-brand-700"> | |
| {project.projectSize} | |
| </p> | |
| )} | |
| <div className="mt-4 flex items-center justify-between"> | |
| <span className="flex flex-wrap items-center gap-2"> | |
| {project.status && ( | |
| <span | |
| className={`rounded px-2 py-1 text-xs font-medium ${getStatusClasses(project.status)}`} | |
| aria-label={`Status: ${project.status}`} | |
| > | |
| {project.status} | |
| </span> | |
| )} | |
| {(project.categories || []).slice(0, 2).map((c) => ( | |
| <span key={c} className="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700"> | |
| {c} | |
| </span> | |
| ))} | |
| </span> | |
| {!project.noDetail && ( | |
| <Link | |
| to={`/project/${project.slug}`} | |
| className="text-sm font-medium text-brand-700 underline-offset-2 hover:underline" | |
| aria-label={`View ${project.title}`} | |
| > | |
| View details | |
| </Link> | |
| )} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </article> | |
| ); | |
| } |