1- import { useRef , useState } from 'react'
1+ import { useMemo , useRef , useState } from 'react'
22import type { Project } from '../../types/contentTypes'
33import CardShape , { SHAPE_KEYS } from './CardShape'
44
@@ -12,6 +12,8 @@ const PREVIEW_COLORS = [
1212]
1313const MAX_VISIBLE_LINKS = 2
1414const VALID_HOSTNAME_PATTERN = / ^ [ a - z 0 - 9 . - ] + $ / i
15+ const WORDPRESS_MSHOTS_BASE = 'https://s.wordpress.com/mshots/v1/'
16+ const WEB_PREVIEW_WIDTH = 1200
1517
1618function 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+
3456export 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