566 lines
34 KiB
TypeScript
566 lines
34 KiB
TypeScript
import "./App.css";
|
|
import { useState } from "react";
|
|
import Markdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
// Components
|
|
import {
|
|
AnimatedBackground,
|
|
ProjectThumb,
|
|
CascadeItem,
|
|
FadeContainer,
|
|
ScrollArea,
|
|
} from "./components";
|
|
|
|
// Hooks
|
|
import {
|
|
useSystemDarkMode,
|
|
useGitHubReadme,
|
|
useGitHubRepoImages,
|
|
} from "./hooks";
|
|
|
|
// Data
|
|
import { profile, featuredProjects, allProjects, skills } from "./data";
|
|
|
|
// Utils
|
|
import { stripHtmlFromMarkdown } from "./utils";
|
|
|
|
export default function PortfolioAboveTheFold() {
|
|
const [active, setActive] = useState<number | null>(null);
|
|
const [displayedProject, setDisplayedProject] = useState<number | null>(
|
|
null,
|
|
);
|
|
const [isContentVisible, setIsContentVisible] = useState(true);
|
|
const isDark = useSystemDarkMode();
|
|
|
|
// Handle project selection with fade transition
|
|
const handleProjectSelect = (projectId: number) => {
|
|
if (projectId === active) return;
|
|
|
|
// Start fade out
|
|
setIsContentVisible(false);
|
|
|
|
// After fade out completes, update the project and fade in
|
|
setTimeout(() => {
|
|
setActive(projectId);
|
|
setDisplayedProject(projectId);
|
|
// Small delay to ensure state is set before fading in
|
|
requestAnimationFrame(() => {
|
|
setIsContentVisible(true);
|
|
});
|
|
}, 200); // Match the fade-out duration
|
|
};
|
|
|
|
// Handle closing with fade transition
|
|
const handleClose = () => {
|
|
setIsContentVisible(false);
|
|
setTimeout(() => {
|
|
setActive(null);
|
|
setDisplayedProject(null);
|
|
requestAnimationFrame(() => {
|
|
setIsContentVisible(true);
|
|
});
|
|
}, 200);
|
|
};
|
|
|
|
// Combine all projects for lookup
|
|
const allProjectsList = [...featuredProjects, ...allProjects];
|
|
|
|
// Get all repo URLs for fetching images
|
|
const allRepos = allProjectsList.map((p) => p.repo);
|
|
|
|
// Fetch README images for all repos (for thumbnails)
|
|
const repoImages = useGitHubRepoImages(allRepos);
|
|
|
|
// Get the active project's repo
|
|
const activeProject = active
|
|
? allProjectsList.find((p) => p.id === active)
|
|
: null;
|
|
const activeRepo = activeProject?.repo || null;
|
|
|
|
// Fetch README from GitHub for the active project
|
|
const {
|
|
content: readmeContent,
|
|
isLoading: readmeLoading,
|
|
error: readmeError,
|
|
image: readmeImage,
|
|
} = useGitHubReadme(activeRepo);
|
|
|
|
return (
|
|
<main className="h-screen w-full flex items-center justify-center p-6 overflow-hidden relative">
|
|
<AnimatedBackground
|
|
palette={activeProject?.palette}
|
|
isDark={isDark}
|
|
/>
|
|
<section className="relative max-w-[1100px] w-full h-[88vh] bg-white/60 dark:bg-slate-900/60 backdrop-blur-sm border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-2xl p-6 grid grid-cols-2 gap-6 items-stretch">
|
|
<ScrollArea className="min-h-0" scrollbarOffset={12}>
|
|
<div className="flex flex-col gap-4">
|
|
<div>
|
|
<p className="text-sm uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Hello, I'm
|
|
</p>
|
|
<h1 className="mt-2 text-[clamp(22px,3.6vw,40px)] leading-tight font-semibold text-gray-900 dark:text-white">
|
|
{profile.name}
|
|
</h1>
|
|
<p className="mt-1 text-[clamp(14px,1.6vw,18px)] text-gray-600 dark:text-gray-300">
|
|
{profile.role} • {profile.location}
|
|
</p>
|
|
|
|
<p className="mt-6 text-[clamp(13px,1.2vw,16px)] text-gray-700 dark:text-gray-300 max-w-[38ch]">
|
|
{profile.blurb}
|
|
</p>
|
|
|
|
<div className="mt-6 flex flex-wrap gap-3">
|
|
<a
|
|
href={profile.cv}
|
|
className="inline-flex items-center gap-2 rounded-md px-4 py-2 border border-gray-200 dark:border-slate-600 shadow-sm text-sm font-medium text-gray-900 dark:text-white hover:shadow hover:-translate-y-0.5 transition-transform"
|
|
>
|
|
Download CV
|
|
</a>
|
|
|
|
<a
|
|
href={`mailto:${profile.email}`}
|
|
className="inline-flex items-center gap-2 rounded-md px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 text-sm font-medium hover:opacity-95 transition-opacity"
|
|
>
|
|
Contact
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2">
|
|
<h3 className="text-xs uppercase text-gray-500 dark:text-gray-400 tracking-wide">
|
|
Featured projects
|
|
</h3>
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
|
{featuredProjects.map((p) => {
|
|
const isActive = active === p.id;
|
|
|
|
return (
|
|
// Wrapper div creates the gradient border effect
|
|
<div
|
|
key={p.id}
|
|
className="group relative rounded-lg p-[2px] transition-all duration-200 shadow-sm hover:shadow-md hover:-translate-y-0.5 bg-white dark:bg-slate-800"
|
|
>
|
|
{/* Gradient border layer - always present, opacity controlled */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 rounded-lg transition-opacity duration-300 pointer-events-none"
|
|
style={{
|
|
backgroundImage: p.gradient,
|
|
opacity: isActive ? 1 : 0,
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() =>
|
|
handleProjectSelect(p.id)
|
|
}
|
|
className={`relative flex flex-col w-full rounded-[6px] overflow-hidden
|
|
bg-white dark:bg-slate-800
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500`}
|
|
aria-expanded={isActive}
|
|
type="button"
|
|
>
|
|
{/* Gradient overlay that covers the entire button; becomes visible on hover. */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none rounded-[6px]"
|
|
style={{
|
|
backgroundImage:
|
|
p.gradient,
|
|
}}
|
|
/>
|
|
|
|
{/* Slight dark layer to insure text contrast when gradient is visible */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition-colors duration-300 pointer-events-none rounded-[6px]"
|
|
/>
|
|
|
|
{/* Image fills width */}
|
|
<div className="relative z-10 w-full h-24 overflow-hidden">
|
|
<ProjectThumb
|
|
src={
|
|
(p.repo &&
|
|
repoImages[
|
|
p.repo
|
|
]) ||
|
|
p.image
|
|
}
|
|
alt={p.title}
|
|
/>
|
|
</div>
|
|
|
|
{/* Text below image */}
|
|
<div className="relative z-10 text-left p-2">
|
|
<div className="text-sm font-medium transition-colors duration-200 text-gray-900 dark:text-white group-hover:text-white">
|
|
{p.title}
|
|
</div>
|
|
<div className="text-xs transition-colors duration-200 text-gray-500 dark:text-gray-400 group-hover:text-gray-100 line-clamp-2">
|
|
{p.desc}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<h3 className="mt-4 text-xs uppercase text-gray-500 dark:text-gray-400 tracking-wide">
|
|
All projects
|
|
</h3>
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
|
{allProjects.map((p) => {
|
|
const isActive = active === p.id;
|
|
|
|
return (
|
|
// Wrapper div creates the gradient border effect
|
|
<div
|
|
key={p.id}
|
|
className="group relative rounded-lg p-[2px] transition-all duration-200 shadow-sm hover:shadow-md hover:-translate-y-0.5 bg-white dark:bg-slate-800"
|
|
>
|
|
{/* Gradient border layer - always present, opacity controlled */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 rounded-lg transition-opacity duration-300 pointer-events-none"
|
|
style={{
|
|
backgroundImage: p.gradient,
|
|
opacity: isActive ? 1 : 0,
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() =>
|
|
handleProjectSelect(p.id)
|
|
}
|
|
className={`relative flex flex-col w-full rounded-[6px] overflow-hidden
|
|
bg-white dark:bg-slate-800
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500`}
|
|
aria-expanded={isActive}
|
|
type="button"
|
|
>
|
|
{/* Gradient overlay that covers the entire button; becomes visible on hover. */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none rounded-[6px]"
|
|
style={{
|
|
backgroundImage:
|
|
p.gradient,
|
|
}}
|
|
/>
|
|
|
|
{/* Slight dark layer to insure text contrast when gradient is visible */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition-colors duration-300 pointer-events-none rounded-[6px]"
|
|
/>
|
|
|
|
{/* Image fills width */}
|
|
<div className="relative z-10 w-full h-24 overflow-hidden">
|
|
<ProjectThumb
|
|
src={
|
|
(p.repo &&
|
|
repoImages[
|
|
p.repo
|
|
]) ||
|
|
p.image
|
|
}
|
|
alt={p.title}
|
|
/>
|
|
</div>
|
|
|
|
{/* Text below image */}
|
|
<div className="relative z-10 text-left p-2">
|
|
<div className="text-sm font-medium transition-colors duration-200 text-gray-900 dark:text-white group-hover:text-white">
|
|
{p.title}
|
|
</div>
|
|
<div className="text-xs transition-colors duration-200 text-gray-500 dark:text-gray-400 group-hover:text-gray-100 line-clamp-2">
|
|
{p.desc}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="flex flex-col gap-4 min-h-0">
|
|
<div className="flex-1 bg-white dark:bg-slate-800 rounded-lg border border-gray-100 dark:border-slate-700 overflow-hidden">
|
|
<div className="w-full h-full flex items-center justify-center relative">
|
|
{/* Project detail view */}
|
|
<FadeContainer
|
|
isVisible={
|
|
displayedProject !== null &&
|
|
isContentVisible
|
|
}
|
|
>
|
|
{displayedProject && (
|
|
<ScrollArea
|
|
className="w-full h-full"
|
|
scrollbarOffset={12}
|
|
>
|
|
<article>
|
|
<CascadeItem
|
|
delay={0}
|
|
isVisible={isContentVisible}
|
|
>
|
|
<div className="w-full aspect-[4/3] rounded-lg overflow-hidden bg-gray-100 dark:bg-slate-700">
|
|
<ProjectThumb
|
|
src={
|
|
readmeImage ||
|
|
(allProjectsList.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)?.image ??
|
|
"")
|
|
}
|
|
alt={
|
|
allProjectsList.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)?.title ?? ""
|
|
}
|
|
/>
|
|
</div>
|
|
</CascadeItem>
|
|
|
|
<CascadeItem
|
|
delay={75}
|
|
isVisible={isContentVisible}
|
|
>
|
|
<div className="mt-4 flex items-start justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{
|
|
allProjectsList.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)?.title
|
|
}
|
|
</h2>
|
|
<button
|
|
onClick={handleClose}
|
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</CascadeItem>
|
|
|
|
<CascadeItem
|
|
delay={150}
|
|
isVisible={isContentVisible}
|
|
>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{allProjectsList
|
|
.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)
|
|
?.tags.map((t, i) => (
|
|
<span
|
|
key={i}
|
|
className="text-xs px-2 py-1 bg-gray-100 dark:bg-slate-700 rounded-md text-gray-700 dark:text-gray-300"
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</CascadeItem>
|
|
|
|
<CascadeItem
|
|
delay={225}
|
|
isVisible={isContentVisible}
|
|
>
|
|
<div className="mt-4">
|
|
{readmeLoading && (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
Loading README...
|
|
</div>
|
|
)}
|
|
{readmeError &&
|
|
!readmeLoading && (
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
{
|
|
allProjectsList.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)?.desc
|
|
}
|
|
</p>
|
|
)}
|
|
{readmeContent &&
|
|
!readmeLoading && (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
|
<Markdown
|
|
remarkPlugins={[
|
|
remarkGfm,
|
|
]}
|
|
components={{
|
|
code: ({
|
|
className,
|
|
children,
|
|
...props
|
|
}) => {
|
|
// Check if this code is inside a pre (fenced code block)
|
|
// by checking if className exists or if it's multi-line
|
|
const isCodeBlock =
|
|
className ||
|
|
(typeof children ===
|
|
"string" &&
|
|
children.includes(
|
|
"\n",
|
|
));
|
|
|
|
if (
|
|
isCodeBlock
|
|
) {
|
|
return (
|
|
<code
|
|
className={`${className || ""} text-sm`}
|
|
{...props}
|
|
>
|
|
{
|
|
children
|
|
}
|
|
</code>
|
|
);
|
|
}
|
|
// Inline code
|
|
return (
|
|
<code
|
|
className="bg-gray-200 dark:bg-slate-700 px-1.5 py-0.5 rounded text-sm font-mono"
|
|
{...props}
|
|
>
|
|
{
|
|
children
|
|
}
|
|
</code>
|
|
);
|
|
},
|
|
pre: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<pre
|
|
className="bg-gray-100 dark:bg-slate-800 p-4 rounded-lg overflow-x-auto text-sm"
|
|
{...props}
|
|
>
|
|
{
|
|
children
|
|
}
|
|
</pre>
|
|
),
|
|
}}
|
|
>
|
|
{stripHtmlFromMarkdown(
|
|
readmeContent,
|
|
)}
|
|
</Markdown>
|
|
</div>
|
|
)}
|
|
{!activeRepo &&
|
|
!readmeLoading && (
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
{
|
|
allProjectsList.find(
|
|
(p) =>
|
|
p.id ===
|
|
displayedProject,
|
|
)?.desc
|
|
}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CascadeItem>
|
|
</article>
|
|
</ScrollArea>
|
|
)}
|
|
</FadeContainer>
|
|
|
|
{/* Empty state view */}
|
|
<FadeContainer
|
|
isVisible={
|
|
displayedProject === null &&
|
|
isContentVisible
|
|
}
|
|
>
|
|
<div className="text-center max-w-[44ch] flex flex-col items-center justify-center h-full">
|
|
<CascadeItem
|
|
delay={0}
|
|
isVisible={
|
|
displayedProject === null &&
|
|
isContentVisible
|
|
}
|
|
>
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
Selected work
|
|
</h2>
|
|
</CascadeItem>
|
|
|
|
<CascadeItem
|
|
delay={75}
|
|
isVisible={
|
|
displayedProject === null &&
|
|
isContentVisible
|
|
}
|
|
>
|
|
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
Click a project on the left to open
|
|
a short preview.
|
|
</p>
|
|
</CascadeItem>
|
|
|
|
<CascadeItem
|
|
delay={150}
|
|
isVisible={
|
|
displayedProject === null &&
|
|
isContentVisible
|
|
}
|
|
>
|
|
<div className="mt-6 flex justify-center gap-3"></div>
|
|
</CascadeItem>
|
|
</div>
|
|
</FadeContainer>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h4 className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
Skills
|
|
</h4>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{skills.map((s, i) => (
|
|
<span
|
|
key={i}
|
|
className="text-xs px-2 py-1 border border-gray-200 dark:border-slate-600 rounded-md text-gray-700 dark:text-gray-300"
|
|
>
|
|
{s}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-gray-500 dark:text-gray-400">
|
|
<div>Available for freelance & contract</div>
|
|
<div className="mt-2">{profile.email}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pointer-events-none absolute bottom-6 right-6 text-[10px] text-gray-400 dark:text-gray-500">
|
|
Built with React + Tailwind
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|