220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
import React, { useState } from "react";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import {
|
|
LineChart, HardDrive, Terminal, Brain, Copy, Check,
|
|
FileCode, Server, Cpu, GitBranch
|
|
} from "lucide-react";
|
|
import { ScramblerText } from "./DynamicTextEffects";
|
|
import { PROJECTS } from "../data";
|
|
import type { ProjectItem } from "../types";
|
|
|
|
const ProjectIconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
brain: Brain,
|
|
"line-chart": LineChart,
|
|
"hard-drive": HardDrive,
|
|
terminal: Terminal,
|
|
"file-code": FileCode,
|
|
server: Server,
|
|
cpu: Cpu,
|
|
"git-branch": GitBranch,
|
|
};
|
|
|
|
function resolveIcon(iconName?: string): React.ComponentType<{ className?: string }> {
|
|
if (iconName && iconName in ProjectIconMap) {
|
|
return ProjectIconMap[iconName];
|
|
}
|
|
return FileCode;
|
|
}
|
|
|
|
function HeroCard({ project }: { project: ProjectItem }) {
|
|
return (
|
|
<div className="md:col-span-8 group relative overflow-hidden bg-zinc-950/25 border border-zinc-900 rounded-lg p-6 md:p-8 flex flex-col justify-end min-h-[400px] hover:border-zinc-700/80 hover:bg-zinc-950/40 transition-all duration-300">
|
|
{project.image && (
|
|
<>
|
|
<div
|
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-[1.025] opacity-25 group-hover:opacity-45 grayscale group-hover:grayscale-0 select-none pointer-events-none"
|
|
style={{ backgroundImage: `url('${project.image}')` }}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/70 to-zinc-950/20" />
|
|
</>
|
|
)}
|
|
|
|
<div className="relative z-10 space-y-3">
|
|
{project.category && (
|
|
<span className="font-mono text-[9px] font-bold text-blue-200 bg-blue-950/80 border border-blue-900 px-2.5 py-1 rounded inline-block uppercase tracking-wider">
|
|
{project.category}
|
|
</span>
|
|
)}
|
|
<h3 className="font-sans text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
|
{project.title}
|
|
</h3>
|
|
<p className="font-sans text-zinc-400 text-sm md:text-base max-w-md leading-relaxed">
|
|
<ScramblerText text={project.description} delay={200} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IconCard({ project }: { project: ProjectItem }) {
|
|
const Icon = resolveIcon(project.icon);
|
|
|
|
return (
|
|
<div className="md:col-span-4 group relative overflow-hidden bg-zinc-950/25 border border-zinc-900 rounded-lg p-6 md:p-8 flex flex-col justify-between min-h-[400px] hover:border-zinc-700/80 hover:bg-zinc-950/40 transition-all duration-300">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.01] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
|
|
<div className="space-y-6 relative z-10">
|
|
<div className="w-12 h-12 rounded bg-zinc-900/60 border border-zinc-800 flex items-center justify-center text-zinc-400 group-hover:text-white group-hover:scale-105 group-hover:border-zinc-700 transition-all duration-300">
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="font-sans text-xl md:text-2xl font-bold text-white tracking-tight">
|
|
{project.title}
|
|
</h3>
|
|
<p className="font-sans text-zinc-400 text-sm leading-relaxed">
|
|
<ScramblerText text={project.description} delay={250} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{project.tags && project.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-4 relative z-10">
|
|
{project.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="font-mono text-[9px] text-zinc-500 border border-zinc-800 px-2 py-0.5 bg-zinc-900/10 rounded"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TerminalCard({ project }: { project: ProjectItem }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const copyCommand = () => {
|
|
if (project.terminalCommand) {
|
|
navigator.clipboard.writeText(project.terminalCommand);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="md:col-span-8 group relative overflow-hidden bg-zinc-950/40 border border-zinc-900 rounded-lg p-6 md:p-8 flex flex-col sm:flex-row items-center gap-8 min-h-[300px] hover:border-zinc-700 hover:bg-zinc-950/40 transition-all duration-300">
|
|
<div className="flex-1 space-y-4">
|
|
<div>
|
|
{project.category && (
|
|
<span className="font-mono text-[9px] text-zinc-500 font-bold mb-1.5 block tracking-widest uppercase">
|
|
<ScramblerText text={project.category} delay={150} />
|
|
</span>
|
|
)}
|
|
<h3 className="font-sans text-xl md:text-2xl font-extrabold text-white tracking-tight leading-snug">
|
|
{project.title}
|
|
</h3>
|
|
</div>
|
|
|
|
<p className="font-sans text-zinc-400 text-sm leading-relaxed">
|
|
<ScramblerText text={project.description} delay={350} />
|
|
</p>
|
|
|
|
<div
|
|
onClick={copyCommand}
|
|
className="group/term relative flex items-center justify-between bg-zinc-900/60 border border-zinc-800 rounded px-4 py-3 cursor-pointer hover:border-white transition-colors duration-300"
|
|
>
|
|
<div className="flex items-center gap-2.5 overflow-hidden">
|
|
<Terminal className="w-4 h-4 text-zinc-500 shrink-0 group-hover/term:text-white" />
|
|
<code className="font-mono text-xs text-zinc-300 group-hover/term:text-white truncate">
|
|
{project.terminalCommand}
|
|
</code>
|
|
</div>
|
|
<div className="shrink-0 pl-2">
|
|
<AnimatePresence mode="wait">
|
|
{copied ? (
|
|
<motion.div
|
|
key="check"
|
|
initial={{ scale: 0.5, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.5, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="copy"
|
|
initial={{ scale: 0.5, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.5, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<Copy className="w-3.5 h-3.5 text-zinc-500 group-hover/term:text-white" />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{project.image && (
|
|
<div className="hidden sm:block w-1/3 aspect-square bg-zinc-950 border border-zinc-800 rounded flex items-center justify-center overflow-hidden">
|
|
<img
|
|
className="w-full h-full object-cover grayscale opacity-55 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700"
|
|
src={project.image}
|
|
alt={`${project.title} preview`}
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SelectedWorks() {
|
|
return (
|
|
<section id="projects" className="relative py-28 max-w-[1200px] mx-auto px-6 overflow-hidden select-none">
|
|
<div className="absolute top-1/4 -left-10 w-[300px] h-[300px] bg-white/[0.01] rounded-full blur-[90px] pointer-events-none" />
|
|
|
|
<div className="mb-14">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -10 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5 }}
|
|
className="space-y-4"
|
|
>
|
|
<h2 className="font-sans text-4xl md:text-[50px] font-extrabold text-white tracking-tight">
|
|
Selected Works
|
|
</h2>
|
|
<div className="w-20 h-1 bg-white" />
|
|
</motion.div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 h-auto">
|
|
{PROJECTS.map((project, index) => (
|
|
<motion.div
|
|
key={project.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
|
className="contents"
|
|
>
|
|
{project.terminalCommand ? (
|
|
<TerminalCard project={project} />
|
|
) : project.image ? (
|
|
<HeroCard project={project} />
|
|
) : (
|
|
<IconCard project={project} />
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|