Files
riotpiao.com/components/ProjectShowcase.tsx
T

257 lines
9.0 KiB
TypeScript
Raw Normal View History

'use client'
import { motion } from 'framer-motion'
import { ExternalLink, MessageSquare, CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTerminal } from '@/lib/TerminalContext'
function RepoCIBadge({ label, repo, forgejoBase }: { label: string; repo: string; forgejoBase: string }) {
const [ci, setCI] = useState<{ sha: string; status: string } | null>(null)
useEffect(() => {
fetch(`/api/ci-status?repo=${repo}`)
.then(r => r.json())
.then(data => setCI({
sha: data.sha?.substring(0, 7) || '',
status: data.conclusion || data.status || 'unknown',
}))
.catch(() => null)
}, [repo])
const icon = !ci ? <Loader2 size={11} className="animate-spin text-gray-400" />
: ci.status === 'success' ? <CheckCircle size={11} className="text-green-500" />
: ci.status === 'failure' ? <XCircle size={11} className="text-red-500" />
: <AlertCircle size={11} className="text-yellow-500" />
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium w-20 truncate">{label}</span>
{ci?.sha ? (
<a
href={`${forgejoBase}/commit/${ci.sha}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-blue-600 dark:text-blue-400 hover:underline"
>
{ci.sha}
</a>
) : (
<span className="font-mono text-gray-400">···</span>
)}
<span className="text-gray-300 dark:text-gray-600">|</span>
{icon}
</div>
)
}
interface CIRepo {
label: string
repo: string
forgejoBase: string
}
interface ProjectShowcaseProps {
id?: string
title: string
description: string
longDescription?: string
stat: string
highlight?: string
status: 'live' | 'building' | 'planning' | 'completed'
media?: {
type: 'image' | 'video'
url: string
}
videoUrl?: string
articleUrl?: string
watchVideoText?: string
readArticleText?: string
askPoimenText?: string
bullets?: string[]
deepDive?: {
label: string
url: string
}
ciRepos?: CIRepo[]
}
export function ProjectShowcase({
id,
title,
description,
longDescription,
stat,
highlight,
status,
media,
videoUrl,
articleUrl,
watchVideoText = 'Watch Video',
readArticleText = 'Read Article',
askPoimenText = 'Ask Poimen for technical details',
bullets,
deepDive,
ciRepos,
}: ProjectShowcaseProps) {
const { setIsOpen } = useTerminal()
const statusColors = {
live: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
completed: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
building: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
planning: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
}
return (
<motion.div
id={id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow"
>
{/* Optional Media (Image or Video) */}
{media && (
<div className="aspect-video bg-gradient-to-br from-blue-500 to-purple-500 dark:from-blue-900 dark:to-purple-900 flex items-center justify-center">
{media.type === 'video' ? (
(() => {
const youtubeMatch = media.url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/)
if (youtubeMatch) {
return (
<iframe
src={`https://www.youtube.com/embed/${youtubeMatch[1]}`}
title={title}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)
}
return null
})()
) : (
<img src={media.url} alt={title} className="w-full h-full object-cover" />
)}
</div>
)}
{/* Content */}
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
{longDescription && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{longDescription}
</p>
)}
{bullets && bullets.length > 0 && (
<ul className="mt-3 space-y-1">
{bullets.map((bullet, i) => {
// Highlight skills: parentheses + keywords
const skillKeywords = ['Terraform', 'Golang', 'K8s', 'Slack', 'Grafana', 'IaC', 'Docker', 'OpenShift', 'Temporal', 'Kafka', 'gRPC', 'AWS', 'Java', 'Python', 'Go', 'C++', 'CloudWatch', 'DynamoDB']
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(\\([^)]+\\)|${skillKeywords.map(escapeRegex).join('|')})`, 'g')
const parts = bullet.split(pattern).filter(Boolean)
return (
<li key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-blue-500"></span>
<span>
{parts.map((part, j) =>
(part.startsWith('(') && part.endsWith(')')) || skillKeywords.includes(part) ? (
<span key={j} className="text-blue-600 dark:text-blue-400 font-medium">
{part}
</span>
) : (
<span key={j}>{part}</span>
)
)}
</span>
</li>
)
})}
</ul>
)}
{deepDive && (
<a
href={deepDive.url}
className="mt-3 inline-block text-sm font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
{deepDive.label}
</a>
)}
</div>
{ciRepos ? (
<div className="ml-4 flex flex-col gap-1.5 shrink-0">
{ciRepos.map((r) => (
<RepoCIBadge key={r.repo} label={r.label} repo={r.repo} forgejoBase={r.forgejoBase} />
))}
</div>
) : (
<span
className={`ml-4 px-3 py-1 rounded-full text-xs font-semibold whitespace-nowrap ${
statusColors[status]
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
)}
</div>
{/* Stat */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded px-4 py-3 mb-4 space-y-1">
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
📊 {stat}
</p>
{highlight && (
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
📊 {highlight}
</p>
)}
</div>
{/* CTA Buttons */}
<div className="flex gap-3">
{videoUrl && videoUrl !== '#' && !videoUrl.includes('youtube.com') && !videoUrl.includes('youtu.be') && (
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold transition flex items-center justify-center gap-2"
>
<span>{watchVideoText}</span>
<ExternalLink size={16} />
</a>
)}
{articleUrl && articleUrl !== '#' && (
<a
href={articleUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 border-2 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 px-4 py-2 rounded-lg font-semibold transition flex items-center justify-center gap-2"
>
<span>{readArticleText}</span>
<ExternalLink size={16} />
</a>
)}
</div>
{/* Ask Poimen */}
<button
onClick={() => setIsOpen(true)}
className="mt-4 w-full text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 flex items-center justify-center gap-2 transition"
>
<MessageSquare size={14} />
{askPoimenText}
</button>
</div>
</motion.div>
)
}