Skip to content

Commit 2a2eaff

Browse files
authored
threejs: redesign project/blog/video cards with preview-driven visuals and fixed-height layout (#37)
1 parent 43ecf2e commit 2a2eaff

3 files changed

Lines changed: 141 additions & 24 deletions

File tree

‎src/templates/threejs/BlogCard.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export default function BlogCard({ blog, excerptLength = 120, index = 0 }: BlogC
5757
return (
5858
<div
5959
ref={cardRef}
60+
className="h-full"
6061
onMouseEnter={() => setHovered(true)}
6162
onMouseMove={handleMouseMove}
6263
onMouseLeave={() => { setHovered(false); setTilt({ x: 0, y: 0 }) }}
@@ -66,7 +67,7 @@ export default function BlogCard({ blog, excerptLength = 120, index = 0 }: BlogC
6667
willChange: 'transform',
6768
}}
6869
>
69-
<article className="group relative flex flex-col rounded-xl border border-blue-900/40 bg-gradient-to-b from-[#0d1220] to-[#080c18] p-5 transition-shadow duration-300 hover:border-blue-500/40 hover:shadow-[0_0_30px_rgba(59,130,246,0.15)]">
70+
<article className="group relative flex h-full min-h-[320px] flex-col rounded-xl border border-blue-900/40 bg-gradient-to-b from-[#0d1220] to-[#080c18] p-5 transition-shadow duration-300 hover:border-blue-500/40 hover:shadow-[0_0_30px_rgba(59,130,246,0.15)]">
7071
{/* Animated top-bar accent */}
7172
<div className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${c.bar} opacity-40 transition-opacity duration-300 group-hover:opacity-100`} />
7273

Lines changed: 119 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,127 @@
1+
import { useRef, useState } from 'react'
12
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+
}
233

334
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+
463
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>
25121
)}
122+
{host && <p className="mt-3 text-[11px] uppercase tracking-[0.16em] text-slate-500">{host}</p>}
26123
</div>
27-
)}
28-
</article>
124+
</article>
125+
</div>
29126
)
30127
}

‎src/templates/threejs/VideoCard.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export default function VideoCard({ video }: { video: Video }) {
3535
return (
3636
<div
3737
ref={cardRef}
38+
className="h-full"
3839
onMouseEnter={() => setHovered(true)}
3940
onMouseMove={handleMouseMove}
4041
onMouseLeave={() => { setHovered(false); setTilt({ x: 0, y: 0 }) }}
@@ -44,7 +45,7 @@ export default function VideoCard({ video }: { video: Video }) {
4445
willChange: 'transform',
4546
}}
4647
>
47-
<article className="group relative flex 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)]">
48+
<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)]">
4849
{/* Animated top-bar accent */}
4950
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-blue-500 to-cyan-400 opacity-40 transition-opacity duration-300 group-hover:opacity-100" />
5051

@@ -75,6 +76,24 @@ export default function VideoCard({ video }: { video: Video }) {
7576
<div className="absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-[#080c18] to-transparent opacity-80" />
7677
</a>
7778
)}
79+
{!thumbnail && (
80+
<a
81+
href={video.videoUrl}
82+
target="_blank"
83+
rel="noreferrer"
84+
className="relative block aspect-video w-full shrink-0 overflow-hidden bg-[#070b16]"
85+
>
86+
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 via-cyan-500/10 to-transparent" />
87+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_15%,rgba(255,255,255,0.2),transparent_45%),radial-gradient(circle_at_80%_78%,rgba(59,130,246,0.22),transparent_40%)]" />
88+
<div className="absolute inset-0 flex items-center justify-center">
89+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500/80 ring-2 ring-white/20 shadow-[0_0_24px_rgba(59,130,246,0.5)] backdrop-blur-sm">
90+
<svg className="ml-0.5 h-5 w-5 fill-white" viewBox="0 0 24 24" aria-hidden="true">
91+
<path d="M8 5v14l11-7z" />
92+
</svg>
93+
</div>
94+
</div>
95+
</a>
96+
)}
7897

7998
<div className="flex flex-1 flex-col p-4">
8099
<h3 className="text-sm font-semibold text-slate-100 leading-snug">

0 commit comments

Comments
 (0)