Skip to content

Commit abc7e1e

Browse files
authored
Render real link snapshots in ThreeJS project card previews with graceful fallback (#38)
1 parent 2a2eaff commit abc7e1e

1 file changed

Lines changed: 51 additions & 6 deletions

File tree

src/templates/threejs/ProjectCard.tsx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState } from 'react'
1+
import { useMemo, useRef, useState } from 'react'
22
import type { Project } from '../../types/contentTypes'
33
import CardShape, { SHAPE_KEYS } from './CardShape'
44

@@ -12,6 +12,8 @@ const PREVIEW_COLORS = [
1212
]
1313
const MAX_VISIBLE_LINKS = 2
1414
const VALID_HOSTNAME_PATTERN = /^[a-z0-9.-]+$/i
15+
const WORDPRESS_MSHOTS_BASE = 'https://s.wordpress.com/mshots/v1/'
16+
const WEB_PREVIEW_WIDTH = 1200
1517

1618
function hashTitle(title: string): number {
1719
let hash = 0
@@ -31,10 +33,34 @@ function fallbackLinkText(url: string): string {
3133
}
3234
}
3335

36+
function getPreviewImageUrl(url: string): string | null {
37+
try {
38+
const parsed = new URL(url)
39+
if (!['http:', 'https:'].includes(parsed.protocol)) return null
40+
41+
const host = parsed.hostname.toLowerCase()
42+
if (host === 'github.com') {
43+
const [owner, repo] = parsed.pathname.split('/').filter(Boolean)
44+
if (owner && repo) {
45+
return `https://opengraph.githubassets.com/1/${owner}/${repo}`
46+
}
47+
return null
48+
}
49+
50+
return `${WORDPRESS_MSHOTS_BASE}${encodeURIComponent(parsed.toString())}?w=${WEB_PREVIEW_WIDTH}`
51+
} catch {
52+
return null
53+
}
54+
}
55+
3456
export default function ProjectCard({ project }: { project: Project }) {
3557
const seed = hashTitle(project.title || project.id)
3658
const color = PREVIEW_COLORS[seed % PREVIEW_COLORS.length]
3759
const previewLink = project.links?.find((link) => link.url)?.url
60+
const previewImageUrl = useMemo(
61+
() => (previewLink ? getPreviewImageUrl(previewLink) : null),
62+
[previewLink],
63+
)
3864
const host = previewLink
3965
? (() => {
4066
try {
@@ -46,6 +72,10 @@ export default function ProjectCard({ project }: { project: Project }) {
4672
}
4773
})()
4874
: null
75+
const isGithubPreview = previewImageUrl?.startsWith('https://opengraph.githubassets.com/') ?? false
76+
const previewAltText = `${isGithubPreview ? 'GitHub repository' : 'Website'} preview for ${project.title || 'project'}${host ? ` (${host})` : ''}`
77+
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null)
78+
const previewFailed = !!previewImageUrl && failedPreviewUrl === previewImageUrl
4979

5080
const cardRef = useRef<HTMLDivElement>(null)
5181
const [tilt, setTilt] = useState({ x: 0, y: 0 })
@@ -81,14 +111,29 @@ export default function ProjectCard({ project }: { project: Project }) {
81111
<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`} />
82112

83113
<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%)]" />
114+
{previewImageUrl && !previewFailed ? (
115+
<img
116+
src={previewImageUrl}
117+
alt={previewAltText}
118+
loading="lazy"
119+
className="absolute inset-0 h-full w-full object-cover"
120+
referrerPolicy="no-referrer"
121+
onError={() => setFailedPreviewUrl(previewImageUrl)}
122+
/>
123+
) : (
124+
<>
125+
<div className={`absolute inset-0 bg-gradient-to-br ${color.bar} opacity-10`} />
126+
<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%)]" />
127+
</>
128+
)}
86129
<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">
87130
Preview
88131
</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>
132+
{(!previewImageUrl || previewFailed) && (
133+
<div className="absolute right-2 top-2 opacity-75 transition-opacity group-hover:opacity-100">
134+
<CardShape index={seed % SHAPE_KEYS.length} color={color.hex} size={64} isHovered={hovered} />
135+
</div>
136+
)}
92137
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#080c18] to-transparent" />
93138
<h3 className="absolute bottom-3 left-3 right-3 line-clamp-2 text-sm font-semibold leading-snug text-slate-100">
94139
{project.title || 'Untitled'}

0 commit comments

Comments
 (0)