Skip to content

Commit a6ad55c

Browse files
committed
feat(portfolio): click-to-open project detail modal
Every project card now opens a centered modal showing the full feature list, contributors, and links. Uses createPortal to document.body so transforms on PageSection do not break fixed positioning. Shell styling matches ExperienceModal (backdrop blur, gradient bg, 720px max width, cinematic easing, cyan ambient glow). Interior accent colors vary by category (Featured/Community/Collab/Others). Subtle 'Click for details' hint on cards with detail content. Keyboard: Enter/Space opens, Esc closes, focus trap and body-scroll lock reused via useFocusTrap. Source and Live Demo links remain on the card and stop propagation so they do not trigger the modal.
1 parent 1ccde20 commit a6ad55c

5 files changed

Lines changed: 600 additions & 4 deletions

File tree

src/pages/portfolio/Portfolio.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import { FILTERS, parseDate } from "./portfolioConstants";
1414
import type { ProjectWithCategory } from "./portfolioConstants";
1515
import ProjectTimeline from "./ProjectTimeline";
1616
import OpenSourceBanner from "./OpenSourceBanner";
17+
import ProjectModal from "./ProjectModal";
1718

1819
const Portfolio = () => {
1920
const [activeFilter, setActiveFilter] = useState<string>("Featured");
21+
const [selectedProject, setSelectedProject] =
22+
useState<ProjectWithCategory | null>(null);
2023

2124
const handleFilterChange = useCallback((filter: string) => {
2225
if (document.startViewTransition) {
@@ -94,6 +97,11 @@ const Portfolio = () => {
9497
otherProjects,
9598
]);
9699

100+
const handleOpenProject = useCallback(
101+
(project: ProjectWithCategory) => setSelectedProject(project),
102+
[],
103+
);
104+
97105
return (
98106
<PageSection id="projects" title="Projects" subtitle="Things I've built">
99107
<div
@@ -176,11 +184,20 @@ const Portfolio = () => {
176184
</motion.div>
177185

178186
{/* Vertical timeline */}
179-
<ProjectTimeline projects={filteredProjects} isMobile={isMobile} />
187+
<ProjectTimeline
188+
projects={filteredProjects}
189+
isMobile={isMobile}
190+
onOpenProject={handleOpenProject}
191+
/>
180192

181193
{/* Open Source Contributions Banner */}
182194
<OpenSourceBanner />
183195
</div>
196+
197+
<ProjectModal
198+
project={selectedProject}
199+
onClose={() => setSelectedProject(null)}
200+
/>
184201
</PageSection>
185202
);
186203
};

src/pages/portfolio/ProjectCard.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,38 @@ import ProjectCardHeader from "./ProjectCardHeader";
1212
interface ProjectCardProps {
1313
data: ProjectWithCategory;
1414
index?: number;
15+
onOpen?: () => void;
1516
}
1617

17-
const ProjectCard = ({ data, index = 0 }: ProjectCardProps) => {
18+
const ProjectCard = ({ data, index = 0, onOpen }: ProjectCardProps) => {
1819
const hasGithub = data.github && data.github !== "" && data.github !== "#";
1920
const hasLive = data.live && data.live !== "" && data.live !== "#";
2021
const colors = CATEGORY_COLORS[data.category] || CATEGORY_COLORS.Others;
2122
const isFeatured = data.category === "Featured";
2223
const isCollab = data.category === "Collab";
2324

25+
const hasDetail =
26+
(data.features?.length ?? 0) > 0 ||
27+
(data.contributors?.length ?? 0) > 0 ||
28+
Boolean(data.description);
29+
const clickable = hasDetail && Boolean(onOpen);
30+
31+
const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
32+
if (!clickable) return;
33+
if (e.key === "Enter" || e.key === " ") {
34+
e.preventDefault();
35+
onOpen?.();
36+
}
37+
};
38+
2439
return (
2540
<motion.div
2641
className="glass-card"
2742
style={{
2843
overflow: "hidden",
2944
display: "flex",
3045
flexDirection: "column",
46+
cursor: clickable ? "pointer" : "default",
3147
}}
3248
layout
3349
variants={scaleRotateIn}
@@ -51,6 +67,13 @@ const ProjectCard = ({ data, index = 0 }: ProjectCardProps) => {
5167
y: -6,
5268
transition: { duration: 0.4, ease: [0.4, 0, 0.2, 1] },
5369
}}
70+
onClick={clickable ? onOpen : undefined}
71+
onKeyDown={clickable ? handleKey : undefined}
72+
role={clickable ? "button" : undefined}
73+
tabIndex={clickable ? 0 : undefined}
74+
aria-label={
75+
clickable ? `View details for ${data.title}` : undefined
76+
}
5477
>
5578
{/* Accent top bar */}
5679
<div
@@ -116,6 +139,23 @@ const ProjectCard = ({ data, index = 0 }: ProjectCardProps) => {
116139
))}
117140
</div>
118141

142+
{/* Click-for-details hint (subtle, shown only when there's real detail to see) */}
143+
{clickable && (
144+
<p
145+
style={{
146+
fontFamily: MONO_FONT,
147+
fontSize: 10,
148+
color: colors.accent,
149+
opacity: 0.7,
150+
marginBottom: 12,
151+
letterSpacing: "0.02em",
152+
}}
153+
aria-hidden="true"
154+
>
155+
Click for details
156+
</p>
157+
)}
158+
119159
{/* Links */}
120160
{(hasGithub || hasLive) && (
121161
<div

src/pages/portfolio/ProjectLink.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const ProjectLink = ({
1919
href={href}
2020
target="_blank"
2121
rel="noopener noreferrer"
22+
onClick={(e) => e.stopPropagation()}
2223
style={{
2324
display: "inline-flex",
2425
alignItems: "center",

0 commit comments

Comments
 (0)