339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import skymoneycover from "../assets/img/Skymoney-cover-img.jpg";
|
|
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
|
|
import millercover from "../assets/img/500nmain-cover-img.jpg";
|
|
import millercoverMobile from "../assets/img/500nmain-mobile-cover-img.jpg";
|
|
import skymoneyvideo from "../assets/video/Skymoney-video.mp4";
|
|
import skymoneyvideoMobile from "../assets/video/Skymoney-mobile-video.mp4";
|
|
import millervideo from "../assets/video/500nmain-video.mp4";
|
|
import millervideoMobile from "../assets/video/500nmain-mobile-video.mp4";
|
|
|
|
type Project = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
coverImage: string;
|
|
coverImageMobile: string;
|
|
video: string;
|
|
videoMobile: string;
|
|
techStack: string[];
|
|
liveUrl?: string;
|
|
beta?: boolean;
|
|
note?: string;
|
|
comingSoon?: boolean;
|
|
};
|
|
|
|
const projects: Project[] = [
|
|
{
|
|
id: "skymoney",
|
|
title: "SkyMoney",
|
|
description:
|
|
"A budgeting app that simulates your bank account to ensure financial discipline.",
|
|
coverImage: skymoneycover,
|
|
coverImageMobile: skymoneycoverMobile,
|
|
video: skymoneyvideo,
|
|
videoMobile: skymoneyvideoMobile,
|
|
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
|
|
liveUrl: "https://skymoneybudget.com",
|
|
beta: true,
|
|
note: "Contact Jody for beta access.",
|
|
},
|
|
{
|
|
id: "miller-building",
|
|
title: "Miller Building Website",
|
|
description:
|
|
"A website showcasing the historic Miller Building located in Borger, Texas.",
|
|
coverImage: millercover,
|
|
coverImageMobile: millercoverMobile,
|
|
video: millervideo,
|
|
videoMobile: millervideoMobile,
|
|
techStack: ["HTML", "CSS", "JQuery"],
|
|
liveUrl: "https://500nmain806.com",
|
|
},
|
|
];
|
|
|
|
function VideoModal({
|
|
isOpen,
|
|
onClose,
|
|
video,
|
|
videoMobile,
|
|
title,
|
|
}: {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
video: string;
|
|
videoMobile: string;
|
|
title: string;
|
|
}) {
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
|
checkMobile();
|
|
window.addEventListener("resize", checkMobile);
|
|
return () => window.removeEventListener("resize", checkMobile);
|
|
}, []);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm anim-fade-in"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="relative w-full max-w-4xl mx-4 bg-bg rounded-2xl overflow-hidden shadow-2xl border border-secondary"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-secondary">
|
|
<h3 className="text-xl font-bold text-text">{title} Demo</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg hover:bg-secondary/50 text-text/70 hover:text-text anim-base"
|
|
aria-label="Close modal"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className={isMobile ? "aspect-[9/16] bg-black" : "aspect-video bg-black"}>
|
|
<video
|
|
src={isMobile ? videoMobile : video}
|
|
controls
|
|
autoPlay
|
|
muted
|
|
className="w-full h-full object-contain"
|
|
>
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectCard({
|
|
project,
|
|
onPlayVideo,
|
|
}: {
|
|
project: Project;
|
|
onPlayVideo: () => void;
|
|
}) {
|
|
return (
|
|
<div className="group relative rounded-2xl border border-secondary bg-secondary/20 overflow-hidden hover:border-primary/50 anim-base hover-pop">
|
|
{/* Cover Image - Desktop */}
|
|
<div className="relative aspect-video overflow-hidden hidden md:block">
|
|
<img
|
|
src={project.coverImage}
|
|
alt={project.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 anim-base"
|
|
/>
|
|
{/* Play Button Overlay */}
|
|
<button
|
|
onClick={onPlayVideo}
|
|
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 anim-base"
|
|
aria-label={`Play ${project.title} demo video`}
|
|
>
|
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/90 text-white hover:bg-primary hover:scale-110 anim-base">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
>
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
{/* Beta Badge */}
|
|
{project.beta && (
|
|
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
|
|
Beta
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cover Image - Mobile */}
|
|
<div className="relative aspect-[9/16] overflow-hidden md:hidden mx-4 my-4 rounded-xl">
|
|
<img
|
|
src={project.coverImageMobile}
|
|
alt={project.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 anim-base"
|
|
/>
|
|
{/* Play Button Overlay */}
|
|
<button
|
|
onClick={onPlayVideo}
|
|
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 anim-base"
|
|
aria-label={`Play ${project.title} demo video`}
|
|
>
|
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/90 text-white hover:bg-primary hover:scale-110 anim-base">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
>
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
{/* Beta Badge */}
|
|
{project.beta && (
|
|
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
|
|
Beta
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-5">
|
|
<h3 className="text-xl font-bold text-text mb-2">{project.title}</h3>
|
|
<p className="text-text/70 text-sm mb-4 line-clamp-2">
|
|
{project.description}
|
|
</p>
|
|
{project.note && (
|
|
<p className="text-text/60 text-xs mb-4">{project.note}</p>
|
|
)}
|
|
|
|
{/* Tech Stack */}
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{project.techStack.map((tech) => (
|
|
<span
|
|
key={tech}
|
|
className="px-2 py-1 text-xs rounded-md bg-secondary/50 text-text/80 border border-secondary"
|
|
>
|
|
{tech}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={onPlayVideo}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 anim-base text-sm font-medium"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
>
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
</svg>
|
|
Watch Demo
|
|
</button>
|
|
{project.liveUrl && (
|
|
<a
|
|
href={project.liveUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-secondary/30 text-text border border-secondary hover:border-primary hover:text-primary anim-base text-sm font-medium"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
<polyline points="15 3 21 3 21 9" />
|
|
<line x1="10" y1="14" x2="21" y2="3" />
|
|
</svg>
|
|
Visit Site
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MoreToComCard() {
|
|
return (
|
|
<div className="relative rounded-2xl border border-dashed border-secondary bg-secondary/10 overflow-hidden flex items-center justify-center min-h-[300px]">
|
|
<div className="text-center p-6">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-secondary/30 flex items-center justify-center">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-text/50"
|
|
>
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-xl font-bold text-text/70 mb-2">More to Come</h3>
|
|
<p className="text-text/50 text-sm">
|
|
Exciting projects in development. Stay tuned!
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Projects() {
|
|
const [activeVideo, setActiveVideo] = useState<Project | null>(null);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 md:px-10 lg:px-16 py-16 md:py-24">
|
|
<div className="text-center mb-12">
|
|
<h2 className="text-3xl md:text-4xl font-extrabold text-text mb-4 font-title">
|
|
Projects
|
|
</h2>
|
|
<p className="text-text/70 max-w-2xl mx-auto">
|
|
A showcase of my work — from concept to deployment. Click on any
|
|
project to watch a demo.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
{projects.map((project) => (
|
|
<ProjectCard
|
|
key={project.id}
|
|
project={project}
|
|
onPlayVideo={() => setActiveVideo(project)}
|
|
/>
|
|
))}
|
|
<MoreToComCard />
|
|
</div>
|
|
|
|
{/* Video Modal */}
|
|
<VideoModal
|
|
isOpen={activeVideo !== null}
|
|
onClose={() => setActiveVideo(null)}
|
|
video={activeVideo?.video || ""}
|
|
videoMobile={activeVideo?.videoMobile || ""}
|
|
title={activeVideo?.title || ""}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|