redesign
This commit is contained in:
@@ -0,0 +1,506 @@
|
|||||||
|
diff --git a/src/App.tsx b/src/App.tsx
|
||||||
|
index 19c9751..78d9f0c 100644
|
||||||
|
--- a/src/App.tsx
|
||||||
|
+++ b/src/App.tsx
|
||||||
|
@@ -5,7 +5,6 @@ import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import {
|
||||||
|
- AnimatedBackground,
|
||||||
|
ProjectThumb,
|
||||||
|
CascadeItem,
|
||||||
|
FadeContainer,
|
||||||
|
@@ -14,7 +13,6 @@ import {
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import {
|
||||||
|
- useSystemDarkMode,
|
||||||
|
useGitHubReadme,
|
||||||
|
useGitHubRepoImages,
|
||||||
|
} from "./hooks";
|
||||||
|
@@ -31,13 +29,27 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
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
|
||||||
|
+ // If on mobile/tablet, trigger the modal instantly without waiting for fade-out
|
||||||
|
+ if (window.innerWidth < 1024) {
|
||||||
|
+ setIsContentVisible(false); // Reset content visibility for the cascade
|
||||||
|
+ setActive(projectId);
|
||||||
|
+ setDisplayedProject(projectId);
|
||||||
|
+
|
||||||
|
+ // Wait for the modal wrapper to begin its CSS transition before starting the cascade
|
||||||
|
+ requestAnimationFrame(() => {
|
||||||
|
+ requestAnimationFrame(() => {
|
||||||
|
+ setIsContentVisible(true);
|
||||||
|
+ });
|
||||||
|
+ });
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Start fade out for desktop
|
||||||
|
setIsContentVisible(false);
|
||||||
|
|
||||||
|
// After fade out completes, update the project and fade in
|
||||||
|
@@ -48,19 +60,31 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsContentVisible(true);
|
||||||
|
});
|
||||||
|
- }, 200); // Match the fade-out duration
|
||||||
|
+ }, 300); // Match the fade-out duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle closing with fade transition
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsContentVisible(false);
|
||||||
|
+
|
||||||
|
+ if (window.innerWidth < 1024) {
|
||||||
|
+ // Instantly start scaling down the modal on mobile
|
||||||
|
+ setActive(null);
|
||||||
|
+ setDisplayedProject(null);
|
||||||
|
+ requestAnimationFrame(() => {
|
||||||
|
+ setIsContentVisible(true);
|
||||||
|
+ });
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // On desktop, wait for fade-out before resetting project
|
||||||
|
setTimeout(() => {
|
||||||
|
setActive(null);
|
||||||
|
setDisplayedProject(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsContentVisible(true);
|
||||||
|
});
|
||||||
|
- }, 200);
|
||||||
|
+ }, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine all projects for lookup
|
||||||
|
@@ -87,40 +111,36 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
} = 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}>
|
||||||
|
+ <main className="h-screen w-full bg-white dark:bg-slate-950 overflow-hidden font-sans text-slate-900 dark:text-slate-100 transition-colors duration-300">
|
||||||
|
+ <section className="mx-auto max-w-[1400px] w-full h-full grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-stretch px-6 py-6 md:px-12 md:py-12 lg:px-16 lg:py-16 relative">
|
||||||
|
+ <ScrollArea className="min-h-0 pr-4 lg:pr-6" scrollbarOffset={12}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
- <p className="text-sm uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
+ <p className="text-sm font-medium 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">
|
||||||
|
+ <h1 className="mt-2 text-[clamp(32px,5vw,56px)] leading-none font-bold tracking-tight 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 className="mt-2 text-[clamp(16px,2vw,22px)] font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
+ {profile.role} <span className="text-gray-300 dark:text-gray-600 mx-2">•</span> {profile.location}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
- <p className="mt-6 text-[clamp(13px,1.2vw,16px)] text-gray-700 dark:text-gray-300 max-w-[38ch]">
|
||||||
|
+ <p className="mt-8 text-base lg:text-lg text-gray-600 dark:text-gray-400 max-w-[45ch] leading-relaxed">
|
||||||
|
{profile.blurb}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
- <div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
+ <div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
<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"
|
||||||
|
+ className="inline-flex items-center gap-2 rounded-full px-6 py-2.5 border border-gray-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
+ className="inline-flex items-center gap-2 rounded-full px-6 py-2.5 bg-gray-900 dark:bg-white text-white dark:text-gray-900 text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
@@ -137,48 +157,24 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
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"
|
||||||
|
+ className={`group relative rounded-xl transition-all duration-200 bg-gray-50 dark:bg-slate-900 border ${
|
||||||
|
+ isActive
|
||||||
|
+ ? "border-gray-900 dark:border-white shadow-sm"
|
||||||
|
+ : "border-transparent hover:border-gray-200 dark:hover:border-slate-700"
|
||||||
|
+ }`}
|
||||||
|
>
|
||||||
|
- {/* 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`}
|
||||||
|
+ className={`relative flex flex-col w-full rounded-xl overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-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">
|
||||||
|
+ <div className="relative z-10 w-full h-32 overflow-hidden bg-gray-200 dark:bg-slate-800">
|
||||||
|
<ProjectThumb
|
||||||
|
src={
|
||||||
|
(p.repo &&
|
||||||
|
@@ -192,11 +188,11 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
</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">
|
||||||
|
+ <div className="relative z-10 text-left p-3">
|
||||||
|
+ <div className="text-sm font-semibold transition-colors duration-200 text-gray-900 dark: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">
|
||||||
|
+ <div className="text-xs transition-colors duration-200 text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{p.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -215,48 +211,24 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
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"
|
||||||
|
+ className={`group relative rounded-xl transition-all duration-200 bg-gray-50 dark:bg-slate-900 border ${
|
||||||
|
+ isActive
|
||||||
|
+ ? "border-gray-900 dark:border-white shadow-sm"
|
||||||
|
+ : "border-transparent hover:border-gray-200 dark:hover:border-slate-700"
|
||||||
|
+ }`}
|
||||||
|
>
|
||||||
|
- {/* 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`}
|
||||||
|
+ className={`relative flex flex-col w-full rounded-xl overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-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">
|
||||||
|
+ <div className="relative z-10 w-full h-32 overflow-hidden bg-gray-200 dark:bg-slate-800">
|
||||||
|
<ProjectThumb
|
||||||
|
src={
|
||||||
|
(p.repo &&
|
||||||
|
@@ -270,11 +242,11 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
</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">
|
||||||
|
+ <div className="relative z-10 text-left p-3">
|
||||||
|
+ <div className="text-sm font-semibold transition-colors duration-200 text-gray-900 dark: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">
|
||||||
|
+ <div className="text-xs transition-colors duration-200 text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{p.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -282,13 +254,59 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <div className="mt-8 pt-8 border-t border-gray-200/60 dark:border-slate-800/60 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6 px-2 pb-8">
|
||||||
|
+ <div>
|
||||||
|
+ <h4 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-widest">
|
||||||
|
+ Skills
|
||||||
|
+ </h4>
|
||||||
|
+ <div className="mt-3 flex flex-wrap gap-2.5">
|
||||||
|
+ {skills.map((s, i) => (
|
||||||
|
+ <span
|
||||||
|
+ key={i}
|
||||||
|
+ className="text-[11px] font-medium tracking-wide px-3 py-1.5 bg-gray-100 dark:bg-slate-800/50 rounded-full text-gray-700 dark:text-gray-300"
|
||||||
|
+ >
|
||||||
|
+ {s}
|
||||||
|
+ </span>
|
||||||
|
+ ))}
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <div className="text-left lg:text-right text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
+ <div className="flex items-center gap-2 lg:justify-end mb-1">
|
||||||
|
+ <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
+ Available for freelance & contract
|
||||||
|
+ </div>
|
||||||
|
+ <a href={`mailto:${profile.email}`} className="hover:text-gray-900 dark:hover:text-white transition-colors">{profile.email}</a>
|
||||||
|
</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={`flex flex-col gap-6 min-h-0 h-full transition-all duration-300 fixed inset-0 z-50 p-4 sm:p-8 bg-slate-50/90 dark:bg-slate-950/90 backdrop-blur-md lg:static lg:p-0 lg:bg-transparent lg:dark:bg-transparent lg:backdrop-blur-none ${
|
||||||
|
+ displayedProject !== null
|
||||||
|
+ ? "opacity-100 pointer-events-auto"
|
||||||
|
+ : "opacity-0 pointer-events-none lg:opacity-100 lg:pointer-events-auto"
|
||||||
|
+ }`}
|
||||||
|
+ onClick={() => {
|
||||||
|
+ // Only close on click if it's acting as a modal (narrow viewports)
|
||||||
|
+ if (window.innerWidth < 1024 && displayedProject !== null) {
|
||||||
|
+ handleClose();
|
||||||
|
+ }
|
||||||
|
+ }}
|
||||||
|
+ >
|
||||||
|
+ <div
|
||||||
|
+ className={`flex-1 bg-white dark:bg-slate-900 lg:rounded-3xl border border-gray-200/60 dark:border-slate-800/60 shadow-xl overflow-hidden rounded-2xl transition-all duration-300 ease-out transform ${
|
||||||
|
+ displayedProject !== null
|
||||||
|
+ ? "translate-y-0 scale-100 opacity-100"
|
||||||
|
+ : "translate-y-8 scale-95 opacity-0 lg:translate-y-0 lg:scale-100 lg:opacity-100"
|
||||||
|
+ }`}
|
||||||
|
+ onClick={(e) => e.stopPropagation()}
|
||||||
|
+ >
|
||||||
|
<div className="w-full h-full flex items-center justify-center relative">
|
||||||
|
{/* Project detail view */}
|
||||||
|
<FadeContainer
|
||||||
|
@@ -302,12 +320,22 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
className="w-full h-full"
|
||||||
|
scrollbarOffset={12}
|
||||||
|
>
|
||||||
|
- <article>
|
||||||
|
+ <article className="p-6 md:p-10">
|
||||||
|
+ <button
|
||||||
|
+ onClick={handleClose}
|
||||||
|
+ className="absolute z-50 top-4 right-4 md:top-6 md:right-6 p-2 rounded-full bg-white/80 dark:bg-slate-900/80 backdrop-blur text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-slate-800 border border-gray-200/50 dark:border-slate-700/50 shadow-sm transition-all lg:hidden"
|
||||||
|
+ aria-label="Close project"
|
||||||
|
+ >
|
||||||
|
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||||
|
+ <line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
+ </svg>
|
||||||
|
+ </button>
|
||||||
|
<CascadeItem
|
||||||
|
delay={0}
|
||||||
|
isVisible={isContentVisible}
|
||||||
|
>
|
||||||
|
- <div className="w-full aspect-[4/3] rounded-lg overflow-hidden bg-gray-100 dark:bg-slate-700">
|
||||||
|
+ <div className="w-full aspect-video rounded-xl overflow-hidden bg-gray-100 dark:bg-slate-800 border border-gray-200/50 dark:border-slate-700/50">
|
||||||
|
<ProjectThumb
|
||||||
|
src={
|
||||||
|
readmeImage ||
|
||||||
|
@@ -333,8 +361,8 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
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">
|
||||||
|
+ <div className="mt-6 flex items-start justify-between">
|
||||||
|
+ <h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{
|
||||||
|
allProjectsList.find(
|
||||||
|
(p) =>
|
||||||
|
@@ -345,9 +373,13 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
- className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||||
|
+ className="hidden lg:block p-2 -mr-2 rounded-full text-gray-400 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-slate-800 transition-colors"
|
||||||
|
+ aria-label="Close project"
|
||||||
|
>
|
||||||
|
- Close
|
||||||
|
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||||
|
+ <line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
+ </svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CascadeItem>
|
||||||
|
@@ -366,7 +398,7 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
?.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"
|
||||||
|
+ className="text-[11px] font-medium tracking-wide uppercase px-2.5 py-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-full text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
@@ -380,13 +412,15 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
>
|
||||||
|
<div className="mt-4">
|
||||||
|
{readmeLoading && (
|
||||||
|
- <div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
- Loading README...
|
||||||
|
+ <div className="flex flex-col gap-3 mt-8 opacity-50 animate-pulse w-full max-w-lg">
|
||||||
|
+ <div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-3/4"></div>
|
||||||
|
+ <div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-1/2"></div>
|
||||||
|
+ <div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{readmeError &&
|
||||||
|
!readmeLoading && (
|
||||||
|
- <p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
+ <p className="text-base text-gray-600 dark:text-gray-400 mt-6 leading-relaxed">
|
||||||
|
{
|
||||||
|
allProjectsList.find(
|
||||||
|
(p) =>
|
||||||
|
@@ -398,7 +432,7 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
)}
|
||||||
|
{readmeContent &&
|
||||||
|
!readmeLoading && (
|
||||||
|
- <div className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
||||||
|
+ <div className="prose prose-base md:prose-lg dark:prose-invert max-w-none text-gray-600 dark:text-gray-300 mt-8 prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400">
|
||||||
|
<Markdown
|
||||||
|
remarkPlugins={[
|
||||||
|
remarkGfm,
|
||||||
|
@@ -468,7 +502,7 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
)}
|
||||||
|
{!activeRepo &&
|
||||||
|
!readmeLoading && (
|
||||||
|
- <p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
+ <p className="text-base text-gray-600 dark:text-gray-400 leading-relaxed mt-6">
|
||||||
|
{
|
||||||
|
allProjectsList.find(
|
||||||
|
(p) =>
|
||||||
|
@@ -487,12 +521,13 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
|
||||||
|
{/* Empty state view */}
|
||||||
|
<FadeContainer
|
||||||
|
+ className="hidden lg:flex"
|
||||||
|
isVisible={
|
||||||
|
displayedProject === null &&
|
||||||
|
isContentVisible
|
||||||
|
}
|
||||||
|
>
|
||||||
|
- <div className="text-center max-w-[44ch] flex flex-col items-center justify-center h-full">
|
||||||
|
+ <div className="text-center max-w-[44ch] flex flex-col items-center justify-center h-full p-6 md:p-10">
|
||||||
|
<CascadeItem
|
||||||
|
delay={0}
|
||||||
|
isVisible={
|
||||||
|
@@ -500,8 +535,8 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
isContentVisible
|
||||||
|
}
|
||||||
|
>
|
||||||
|
- <h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
- Selected work
|
||||||
|
+ <h2 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
+ Selected Work
|
||||||
|
</h2>
|
||||||
|
</CascadeItem>
|
||||||
|
|
||||||
|
@@ -512,9 +547,9 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
isContentVisible
|
||||||
|
}
|
||||||
|
>
|
||||||
|
- <p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
+ <p className="mt-4 text-base text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||||
|
Click a project on the left to open
|
||||||
|
- a short preview.
|
||||||
|
+ a quick preview and read more.
|
||||||
|
</p>
|
||||||
|
</CascadeItem>
|
||||||
|
|
||||||
|
@@ -531,29 +566,6 @@ export default function PortfolioAboveTheFold() {
|
||||||
|
</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">
|
||||||
+142
-131
@@ -5,7 +5,6 @@ import remarkGfm from "remark-gfm";
|
|||||||
|
|
||||||
// Components
|
// Components
|
||||||
import {
|
import {
|
||||||
AnimatedBackground,
|
|
||||||
ProjectThumb,
|
ProjectThumb,
|
||||||
CascadeItem,
|
CascadeItem,
|
||||||
FadeContainer,
|
FadeContainer,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
import {
|
import {
|
||||||
useSystemDarkMode,
|
|
||||||
useGitHubReadme,
|
useGitHubReadme,
|
||||||
useGitHubRepoImages,
|
useGitHubRepoImages,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
@@ -31,13 +29,27 @@ export default function PortfolioAboveTheFold() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isContentVisible, setIsContentVisible] = useState(true);
|
const [isContentVisible, setIsContentVisible] = useState(true);
|
||||||
const isDark = useSystemDarkMode();
|
|
||||||
|
|
||||||
// Handle project selection with fade transition
|
// Handle project selection with fade transition
|
||||||
const handleProjectSelect = (projectId: number) => {
|
const handleProjectSelect = (projectId: number) => {
|
||||||
if (projectId === active) return;
|
if (projectId === active) return;
|
||||||
|
|
||||||
// Start fade out
|
// If on mobile/tablet, trigger the modal instantly without waiting for fade-out
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
setIsContentVisible(false); // Reset content visibility for the cascade
|
||||||
|
setActive(projectId);
|
||||||
|
setDisplayedProject(projectId);
|
||||||
|
|
||||||
|
// Wait for the modal wrapper to begin its CSS transition before starting the cascade
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsContentVisible(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fade out for desktop
|
||||||
setIsContentVisible(false);
|
setIsContentVisible(false);
|
||||||
|
|
||||||
// After fade out completes, update the project and fade in
|
// After fade out completes, update the project and fade in
|
||||||
@@ -48,19 +60,31 @@ export default function PortfolioAboveTheFold() {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setIsContentVisible(true);
|
setIsContentVisible(true);
|
||||||
});
|
});
|
||||||
}, 200); // Match the fade-out duration
|
}, 300); // Match the fade-out duration
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle closing with fade transition
|
// Handle closing with fade transition
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsContentVisible(false);
|
setIsContentVisible(false);
|
||||||
|
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
// Instantly start scaling down the modal on mobile
|
||||||
|
setActive(null);
|
||||||
|
setDisplayedProject(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsContentVisible(true);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On desktop, wait for fade-out before resetting project
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setActive(null);
|
setActive(null);
|
||||||
setDisplayedProject(null);
|
setDisplayedProject(null);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setIsContentVisible(true);
|
setIsContentVisible(true);
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine all projects for lookup
|
// Combine all projects for lookup
|
||||||
@@ -87,40 +111,36 @@ export default function PortfolioAboveTheFold() {
|
|||||||
} = useGitHubReadme(activeRepo);
|
} = useGitHubReadme(activeRepo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-screen w-full flex items-center justify-center p-6 overflow-hidden relative">
|
<main className="h-screen w-full bg-white dark:bg-slate-950 overflow-hidden font-sans text-slate-900 dark:text-slate-100 transition-colors duration-300">
|
||||||
<AnimatedBackground
|
<section className="mx-auto max-w-[1400px] w-full h-full grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-stretch px-6 py-6 md:px-12 md:py-12 lg:px-16 lg:py-16 relative">
|
||||||
palette={activeProject?.palette}
|
<ScrollArea className="min-h-0 pr-4 lg:pr-6" scrollbarOffset={12}>
|
||||||
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 className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<p className="text-sm font-medium tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
Hello, I'm
|
Hello, I'm
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-[clamp(22px,3.6vw,40px)] leading-tight font-semibold text-gray-900 dark:text-white">
|
<h1 className="mt-2 text-[clamp(32px,5vw,56px)] leading-none font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-[clamp(14px,1.6vw,18px)] text-gray-600 dark:text-gray-300">
|
<p className="mt-2 text-[clamp(16px,2vw,22px)] font-medium text-gray-600 dark:text-gray-300">
|
||||||
{profile.role} • {profile.location}
|
{profile.role} <span className="text-gray-300 dark:text-gray-600 mx-2">•</span> {profile.location}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-6 text-[clamp(13px,1.2vw,16px)] text-gray-700 dark:text-gray-300 max-w-[38ch]">
|
<p className="mt-8 text-base lg:text-lg text-gray-600 dark:text-gray-400 max-w-[45ch] leading-relaxed">
|
||||||
{profile.blurb}
|
{profile.blurb}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
<a
|
<a
|
||||||
href={profile.cv}
|
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"
|
className="inline-flex items-center gap-2 rounded-full px-6 py-2.5 border border-gray-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
>
|
>
|
||||||
Download CV
|
Download CV
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`mailto:${profile.email}`}
|
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"
|
className="inline-flex items-center gap-2 rounded-full px-6 py-2.5 bg-gray-900 dark:bg-white text-white dark:text-gray-900 text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
@@ -137,48 +157,24 @@ export default function PortfolioAboveTheFold() {
|
|||||||
const isActive = active === p.id;
|
const isActive = active === p.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Wrapper div creates the gradient border effect
|
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
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"
|
className={`group relative rounded-xl transition-all duration-200 bg-gray-50 dark:bg-slate-900 border ${
|
||||||
|
isActive
|
||||||
|
? "border-gray-900 dark:border-white shadow-sm"
|
||||||
|
: "border-transparent hover:border-gray-200 dark:hover:border-slate-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* 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
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleProjectSelect(p.id)
|
handleProjectSelect(p.id)
|
||||||
}
|
}
|
||||||
className={`relative flex flex-col w-full rounded-[6px] overflow-hidden
|
className={`relative flex flex-col w-full rounded-xl overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-500`}
|
||||||
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}
|
aria-expanded={isActive}
|
||||||
type="button"
|
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 */}
|
{/* Image fills width */}
|
||||||
<div className="relative z-10 w-full h-24 overflow-hidden">
|
<div className="relative z-10 w-full h-32 overflow-hidden bg-gray-200 dark:bg-slate-800">
|
||||||
<ProjectThumb
|
<ProjectThumb
|
||||||
src={
|
src={
|
||||||
(p.repo &&
|
(p.repo &&
|
||||||
@@ -192,11 +188,11 @@ export default function PortfolioAboveTheFold() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text below image */}
|
{/* Text below image */}
|
||||||
<div className="relative z-10 text-left p-2">
|
<div className="relative z-10 text-left p-3">
|
||||||
<div className="text-sm font-medium transition-colors duration-200 text-gray-900 dark:text-white group-hover:text-white">
|
<div className="text-sm font-semibold transition-colors duration-200 text-gray-900 dark:text-white">
|
||||||
{p.title}
|
{p.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs transition-colors duration-200 text-gray-500 dark:text-gray-400 group-hover:text-gray-100 line-clamp-2">
|
<div className="text-xs transition-colors duration-200 text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
{p.desc}
|
{p.desc}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,48 +211,24 @@ export default function PortfolioAboveTheFold() {
|
|||||||
const isActive = active === p.id;
|
const isActive = active === p.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Wrapper div creates the gradient border effect
|
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
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"
|
className={`group relative rounded-xl transition-all duration-200 bg-gray-50 dark:bg-slate-900 border ${
|
||||||
|
isActive
|
||||||
|
? "border-gray-900 dark:border-white shadow-sm"
|
||||||
|
: "border-transparent hover:border-gray-200 dark:hover:border-slate-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* 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
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleProjectSelect(p.id)
|
handleProjectSelect(p.id)
|
||||||
}
|
}
|
||||||
className={`relative flex flex-col w-full rounded-[6px] overflow-hidden
|
className={`relative flex flex-col w-full rounded-xl overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-500`}
|
||||||
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}
|
aria-expanded={isActive}
|
||||||
type="button"
|
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 */}
|
{/* Image fills width */}
|
||||||
<div className="relative z-10 w-full h-24 overflow-hidden">
|
<div className="relative z-10 w-full h-32 overflow-hidden bg-gray-200 dark:bg-slate-800">
|
||||||
<ProjectThumb
|
<ProjectThumb
|
||||||
src={
|
src={
|
||||||
(p.repo &&
|
(p.repo &&
|
||||||
@@ -270,11 +242,11 @@ export default function PortfolioAboveTheFold() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text below image */}
|
{/* Text below image */}
|
||||||
<div className="relative z-10 text-left p-2">
|
<div className="relative z-10 text-left p-3">
|
||||||
<div className="text-sm font-medium transition-colors duration-200 text-gray-900 dark:text-white group-hover:text-white">
|
<div className="text-sm font-semibold transition-colors duration-200 text-gray-900 dark:text-white">
|
||||||
{p.title}
|
{p.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs transition-colors duration-200 text-gray-500 dark:text-gray-400 group-hover:text-gray-100 line-clamp-2">
|
<div className="text-xs transition-colors duration-200 text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
{p.desc}
|
{p.desc}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,10 +257,55 @@ export default function PortfolioAboveTheFold() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-gray-200/60 dark:border-slate-800/60 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6 px-2 pb-8">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-widest">
|
||||||
|
Skills
|
||||||
|
</h4>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||||
|
{skills.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[11px] font-medium tracking-wide px-3 py-1.5 bg-gray-100 dark:bg-slate-800/50 rounded-full text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-left lg:text-right text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-2 lg:justify-end mb-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
Available for freelance & contract
|
||||||
|
</div>
|
||||||
|
<a href={`mailto:${profile.email}`} className="hover:text-gray-900 dark:hover:text-white transition-colors">{profile.email}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 min-h-0">
|
<div
|
||||||
<div className="flex-1 bg-white dark:bg-slate-800 rounded-lg border border-gray-100 dark:border-slate-700 overflow-hidden">
|
className={`flex flex-col gap-6 min-h-0 h-full transition-all duration-300 fixed inset-0 z-50 p-4 sm:p-8 bg-slate-50/90 dark:bg-slate-950/90 backdrop-blur-md lg:static lg:p-0 lg:bg-transparent lg:dark:bg-transparent lg:backdrop-blur-none ${
|
||||||
|
displayedProject !== null
|
||||||
|
? "opacity-100 pointer-events-auto"
|
||||||
|
: "opacity-0 pointer-events-none lg:opacity-100 lg:pointer-events-auto"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
// Only close on click if it's acting as a modal (narrow viewports)
|
||||||
|
if (window.innerWidth < 1024 && displayedProject !== null) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex-1 bg-white dark:bg-slate-900 lg:rounded-3xl border border-gray-200/60 dark:border-slate-800/60 shadow-xl overflow-hidden rounded-2xl transition-all duration-300 ease-out transform ${
|
||||||
|
displayedProject !== null
|
||||||
|
? "translate-y-0 scale-100 opacity-100"
|
||||||
|
: "translate-y-8 scale-95 opacity-0 lg:translate-y-0 lg:scale-100 lg:opacity-100"
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="w-full h-full flex items-center justify-center relative">
|
<div className="w-full h-full flex items-center justify-center relative">
|
||||||
{/* Project detail view */}
|
{/* Project detail view */}
|
||||||
<FadeContainer
|
<FadeContainer
|
||||||
@@ -302,12 +319,22 @@ export default function PortfolioAboveTheFold() {
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
scrollbarOffset={12}
|
scrollbarOffset={12}
|
||||||
>
|
>
|
||||||
<article>
|
<article className="p-6 md:p-10">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute z-50 top-4 right-4 md:top-6 md:right-6 p-2 rounded-full bg-white/80 dark:bg-slate-900/80 backdrop-blur text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-slate-800 border border-gray-200/50 dark:border-slate-700/50 shadow-sm transition-all lg:hidden"
|
||||||
|
aria-label="Close project"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<CascadeItem
|
<CascadeItem
|
||||||
delay={0}
|
delay={0}
|
||||||
isVisible={isContentVisible}
|
isVisible={isContentVisible}
|
||||||
>
|
>
|
||||||
<div className="w-full aspect-[4/3] rounded-lg overflow-hidden bg-gray-100 dark:bg-slate-700">
|
<div className="w-full aspect-video rounded-xl overflow-hidden bg-gray-100 dark:bg-slate-800 border border-gray-200/50 dark:border-slate-700/50">
|
||||||
<ProjectThumb
|
<ProjectThumb
|
||||||
src={
|
src={
|
||||||
readmeImage ||
|
readmeImage ||
|
||||||
@@ -333,8 +360,8 @@ export default function PortfolioAboveTheFold() {
|
|||||||
delay={75}
|
delay={75}
|
||||||
isVisible={isContentVisible}
|
isVisible={isContentVisible}
|
||||||
>
|
>
|
||||||
<div className="mt-4 flex items-start justify-between">
|
<div className="mt-6 flex items-start justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
{
|
{
|
||||||
allProjectsList.find(
|
allProjectsList.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -345,9 +372,13 @@ export default function PortfolioAboveTheFold() {
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
className="hidden lg:block p-2 -mr-2 rounded-full text-gray-400 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-slate-800 transition-colors"
|
||||||
|
aria-label="Close project"
|
||||||
>
|
>
|
||||||
Close
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CascadeItem>
|
</CascadeItem>
|
||||||
@@ -366,7 +397,7 @@ export default function PortfolioAboveTheFold() {
|
|||||||
?.tags.map((t, i) => (
|
?.tags.map((t, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
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"
|
className="text-[11px] font-medium tracking-wide uppercase px-2.5 py-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-full text-gray-600 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
</span>
|
</span>
|
||||||
@@ -380,13 +411,15 @@ export default function PortfolioAboveTheFold() {
|
|||||||
>
|
>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{readmeLoading && (
|
{readmeLoading && (
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="flex flex-col gap-3 mt-8 opacity-50 animate-pulse w-full max-w-lg">
|
||||||
Loading README...
|
<div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-3/4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-1/2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-slate-800 rounded-full w-5/6"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{readmeError &&
|
{readmeError &&
|
||||||
!readmeLoading && (
|
!readmeLoading && (
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-base text-gray-600 dark:text-gray-400 mt-6 leading-relaxed">
|
||||||
{
|
{
|
||||||
allProjectsList.find(
|
allProjectsList.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -398,7 +431,7 @@ export default function PortfolioAboveTheFold() {
|
|||||||
)}
|
)}
|
||||||
{readmeContent &&
|
{readmeContent &&
|
||||||
!readmeLoading && (
|
!readmeLoading && (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
<div className="prose prose-base md:prose-lg dark:prose-invert max-w-none text-gray-600 dark:text-gray-300 mt-8 prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400">
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[
|
remarkPlugins={[
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
@@ -468,7 +501,7 @@ export default function PortfolioAboveTheFold() {
|
|||||||
)}
|
)}
|
||||||
{!activeRepo &&
|
{!activeRepo &&
|
||||||
!readmeLoading && (
|
!readmeLoading && (
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-base text-gray-600 dark:text-gray-400 leading-relaxed mt-6">
|
||||||
{
|
{
|
||||||
allProjectsList.find(
|
allProjectsList.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -487,12 +520,13 @@ export default function PortfolioAboveTheFold() {
|
|||||||
|
|
||||||
{/* Empty state view */}
|
{/* Empty state view */}
|
||||||
<FadeContainer
|
<FadeContainer
|
||||||
|
className="hidden lg:flex"
|
||||||
isVisible={
|
isVisible={
|
||||||
displayedProject === null &&
|
displayedProject === null &&
|
||||||
isContentVisible
|
isContentVisible
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="text-center max-w-[44ch] flex flex-col items-center justify-center h-full">
|
<div className="text-center max-w-[44ch] flex flex-col items-center justify-center h-full p-6 md:p-10">
|
||||||
<CascadeItem
|
<CascadeItem
|
||||||
delay={0}
|
delay={0}
|
||||||
isVisible={
|
isVisible={
|
||||||
@@ -500,8 +534,8 @@ export default function PortfolioAboveTheFold() {
|
|||||||
isContentVisible
|
isContentVisible
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
Selected work
|
Selected Work
|
||||||
</h2>
|
</h2>
|
||||||
</CascadeItem>
|
</CascadeItem>
|
||||||
|
|
||||||
@@ -512,9 +546,9 @@ export default function PortfolioAboveTheFold() {
|
|||||||
isContentVisible
|
isContentVisible
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-4 text-base text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||||
Click a project on the left to open
|
Click a project on the left to open
|
||||||
a short preview.
|
a quick preview and read more.
|
||||||
</p>
|
</p>
|
||||||
</CascadeItem>
|
</CascadeItem>
|
||||||
|
|
||||||
@@ -531,29 +565,6 @@ export default function PortfolioAboveTheFold() {
|
|||||||
</FadeContainer>
|
</FadeContainer>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute bottom-6 right-6 text-[10px] text-gray-400 dark:text-gray-500">
|
<div className="pointer-events-none absolute bottom-6 right-6 text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import React from "react";
|
|||||||
interface FadeContainerProps {
|
interface FadeContainerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FadeContainer: fades content in/out using CSS transitions only
|
// FadeContainer: fades content in/out using CSS transitions only
|
||||||
export default function FadeContainer({
|
export default function FadeContainer({
|
||||||
children,
|
children,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
className = "",
|
||||||
}: FadeContainerProps) {
|
}: FadeContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="transition-opacity duration-200 ease-out w-full h-full absolute inset-0 flex items-center justify-center p-6"
|
className={`transition-opacity duration-200 ease-out w-full h-full absolute inset-0 ${className}`}
|
||||||
style={{
|
style={{
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
pointerEvents: isVisible ? "auto" : "none",
|
pointerEvents: isVisible ? "auto" : "none",
|
||||||
|
|||||||
Reference in New Issue
Block a user