201 lines
7.1 KiB
TypeScript
201 lines
7.1 KiB
TypeScript
'use client'
|
|||
|
|
|
||
|
|
import { motion } from 'framer-motion'
|
||
|
|
import { ExternalLink, MessageSquare } from 'lucide-react'
|
||
|
|
import { useTerminal } from '@/lib/TerminalContext'
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
}: 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++']
|
||
|
|
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>
|
||
|
|
<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>
|
||
|
|
)
|
||
|
|
}
|