initial commit
This commit is contained in:
+565
@@ -0,0 +1,565 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user