|
| 1 | +import { useRef, useState } from 'react' |
1 | 2 | import type { Project } from '../../types/contentTypes' |
| 3 | +import CardShape, { SHAPE_KEYS } from './CardShape' |
| 4 | + |
| 5 | +const PREVIEW_COLORS = [ |
| 6 | + { hex: '#3b82f6', bar: 'from-blue-500 to-cyan-400', glow: 'from-blue-500' }, |
| 7 | + { hex: '#8b5cf6', bar: 'from-purple-500 to-pink-400', glow: 'from-purple-500' }, |
| 8 | + { hex: '#06b6d4', bar: 'from-cyan-500 to-blue-400', glow: 'from-cyan-500' }, |
| 9 | + { hex: '#10b981', bar: 'from-emerald-500 to-teal-400', glow: 'from-emerald-500' }, |
| 10 | + { hex: '#f59e0b', bar: 'from-amber-500 to-orange-400', glow: 'from-amber-500' }, |
| 11 | + { hex: '#ec4899', bar: 'from-pink-500 to-rose-400', glow: 'from-pink-500' }, |
| 12 | +] |
| 13 | +const MAX_VISIBLE_LINKS = 2 |
| 14 | +const VALID_HOSTNAME_PATTERN = /^[a-z0-9.-]+$/i |
| 15 | + |
| 16 | +function hashTitle(title: string): number { |
| 17 | + let hash = 0 |
| 18 | + // 31 is a common prime multiplier for lightweight deterministic string hashing. |
| 19 | + for (let i = 0; i < title.length; i += 1) hash = (hash * 31 + title.charCodeAt(i)) >>> 0 |
| 20 | + return hash |
| 21 | +} |
| 22 | + |
| 23 | +function fallbackLinkText(url: string): string { |
| 24 | + try { |
| 25 | + const { hostname, pathname } = new URL(url) |
| 26 | + const cleanHost = hostname.replace(/^www\./, '') |
| 27 | + const cleanPath = pathname === '/' ? '' : pathname |
| 28 | + return `${cleanHost}${cleanPath}` |
| 29 | + } catch { |
| 30 | + return url |
| 31 | + } |
| 32 | +} |
2 | 33 |
|
3 | 34 | export default function ProjectCard({ project }: { project: Project }) { |
| 35 | + const seed = hashTitle(project.title || project.id) |
| 36 | + const color = PREVIEW_COLORS[seed % PREVIEW_COLORS.length] |
| 37 | + const previewLink = project.links?.find((link) => link.url)?.url |
| 38 | + const host = previewLink |
| 39 | + ? (() => { |
| 40 | + try { |
| 41 | + const hostname = new URL(previewLink).hostname.replace(/^www\./, '') |
| 42 | + return VALID_HOSTNAME_PATTERN.test(hostname) ? hostname : null |
| 43 | + } catch { |
| 44 | + // Ignore invalid URLs from user content and continue without hostname text. |
| 45 | + return null |
| 46 | + } |
| 47 | + })() |
| 48 | + : null |
| 49 | + |
| 50 | + const cardRef = useRef<HTMLDivElement>(null) |
| 51 | + const [tilt, setTilt] = useState({ x: 0, y: 0 }) |
| 52 | + const [hovered, setHovered] = useState(false) |
| 53 | + |
| 54 | + const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { |
| 55 | + const el = cardRef.current |
| 56 | + if (!el) return |
| 57 | + const rect = el.getBoundingClientRect() |
| 58 | + const px = (e.clientX - rect.left) / rect.width |
| 59 | + const py = (e.clientY - rect.top) / rect.height |
| 60 | + setTilt({ x: (py - 0.5) * -6, y: (px - 0.5) * 6 }) |
| 61 | + } |
| 62 | + |
4 | 63 | return ( |
5 | | - <article className="flex flex-col rounded-xl border border-blue-900/40 bg-gradient-to-b from-[#0d1220] to-[#080c18] p-5 transition hover:-translate-y-0.5 hover:border-blue-500/50 hover:shadow-[0_0_30px_rgba(59,130,246,0.15)]"> |
6 | | - <h3 className="text-sm font-semibold text-slate-100">{project.title || 'Untitled'}</h3> |
7 | | - {project.description && ( |
8 | | - <p className="mt-2 line-clamp-3 text-[13px] text-slate-400">{project.description}</p> |
9 | | - )} |
10 | | - {project.links && project.links.length > 0 && ( |
11 | | - <div className="mt-3 flex flex-wrap gap-3"> |
12 | | - {project.links.map((link, i) => |
13 | | - link.url ? ( |
14 | | - <a |
15 | | - key={i} |
16 | | - href={link.url} |
17 | | - target="_blank" |
18 | | - rel="noreferrer" |
19 | | - className="inline-flex items-center gap-1 text-xs font-medium text-blue-400 hover:text-blue-300 hover:underline" |
20 | | - > |
21 | | - {link.label || link.url} |
22 | | - <span aria-hidden="true">↗</span> |
23 | | - </a> |
24 | | - ) : null, |
| 64 | + <div |
| 65 | + ref={cardRef} |
| 66 | + className="h-full" |
| 67 | + onMouseEnter={() => setHovered(true)} |
| 68 | + onMouseMove={handleMouseMove} |
| 69 | + onMouseLeave={() => { |
| 70 | + setHovered(false) |
| 71 | + setTilt({ x: 0, y: 0 }) |
| 72 | + }} |
| 73 | + style={{ |
| 74 | + transform: `perspective(700px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg)`, |
| 75 | + transition: hovered ? 'transform 0.08s linear' : 'transform 0.35s ease-out', |
| 76 | + willChange: 'transform', |
| 77 | + }} |
| 78 | + > |
| 79 | + <article className="group relative flex h-full min-h-[320px] flex-col overflow-hidden rounded-xl border border-blue-900/40 bg-gradient-to-b from-[#0d1220] to-[#080c18] transition-shadow duration-300 hover:border-blue-500/50 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]"> |
| 80 | + <div className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${color.bar} opacity-50 transition-opacity duration-300 group-hover:opacity-100`} /> |
| 81 | + <div className={`pointer-events-none absolute -right-6 -top-6 h-20 w-20 rounded-full bg-gradient-to-br ${color.glow} to-transparent opacity-0 blur-2xl transition-opacity duration-500 group-hover:opacity-20`} /> |
| 82 | + |
| 83 | + <div className="relative h-36 overflow-hidden border-b border-blue-900/30 bg-[#070b16]"> |
| 84 | + <div className={`absolute inset-0 bg-gradient-to-br ${color.bar} opacity-10`} /> |
| 85 | + <div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_18%,rgba(255,255,255,0.18),transparent_46%),radial-gradient(circle_at_85%_78%,rgba(255,255,255,0.12),transparent_42%)]" /> |
| 86 | + <div className="absolute left-3 top-3 rounded-full border border-blue-300/25 bg-[#070b16]/75 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-blue-200/85 backdrop-blur-sm"> |
| 87 | + Preview |
| 88 | + </div> |
| 89 | + <div className="absolute right-2 top-2 opacity-75 transition-opacity group-hover:opacity-100"> |
| 90 | + <CardShape index={seed % SHAPE_KEYS.length} color={color.hex} size={64} isHovered={hovered} /> |
| 91 | + </div> |
| 92 | + <div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#080c18] to-transparent" /> |
| 93 | + <h3 className="absolute bottom-3 left-3 right-3 line-clamp-2 text-sm font-semibold leading-snug text-slate-100"> |
| 94 | + {project.title || 'Untitled'} |
| 95 | + </h3> |
| 96 | + </div> |
| 97 | + |
| 98 | + <div className="flex flex-1 flex-col p-4"> |
| 99 | + {project.description && ( |
| 100 | + <p className="line-clamp-3 text-[13px] leading-relaxed text-slate-400 transition-colors group-hover:text-slate-300"> |
| 101 | + {project.description} |
| 102 | + </p> |
| 103 | + )} |
| 104 | + {project.links && project.links.length > 0 && ( |
| 105 | + <div className="mt-auto flex flex-wrap gap-2 pt-4"> |
| 106 | + {project.links.slice(0, MAX_VISIBLE_LINKS).map((link, i) => |
| 107 | + link.url ? ( |
| 108 | + <a |
| 109 | + key={i} |
| 110 | + href={link.url} |
| 111 | + target="_blank" |
| 112 | + rel="noreferrer" |
| 113 | + className="inline-flex items-center gap-1 rounded-full border border-blue-500/25 bg-blue-500/10 px-2.5 py-1 text-[11px] font-medium text-blue-300 transition hover:border-blue-400/40 hover:bg-blue-500/20" |
| 114 | + > |
| 115 | + {link.label || fallbackLinkText(link.url)} |
| 116 | + <span aria-hidden="true">↗</span> |
| 117 | + </a> |
| 118 | + ) : null, |
| 119 | + )} |
| 120 | + </div> |
25 | 121 | )} |
| 122 | + {host && <p className="mt-3 text-[11px] uppercase tracking-[0.16em] text-slate-500">{host}</p>} |
26 | 123 | </div> |
27 | | - )} |
28 | | - </article> |
| 124 | + </article> |
| 125 | + </div> |
29 | 126 | ) |
30 | 127 | } |
0 commit comments