initial commit

This commit is contained in:
Zacharias-Brohn
2026-01-14 10:46:21 +01:00
commit 5c123db557
32 changed files with 7430 additions and 0 deletions
+565
View File
@@ -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>
);
}