stable state

This commit is contained in:
achraf
2026-04-01 00:41:31 +02:00
parent a4ee12732d
commit 8e62a0fa92
16 changed files with 779 additions and 565 deletions

View File

@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef } from "react";
import SafeImage from "./SafeImage";
interface ContactInfo {
@@ -18,21 +17,6 @@ interface ContactProps {
}
export default function Contact({ contact, fullName, image }: ContactProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const links = [
{
label: "Email",
@@ -82,7 +66,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
return (
<section id="contact" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={ref} className="reveal">
<div>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Let&apos;s Connect</div>
<h2
className="font-display"
@@ -274,6 +258,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
>
<div
className="font-mono"
suppressHydrationWarning
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",

View File

@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef } from "react";
import SafeImage from "./SafeImage";
interface EducationItem {
@@ -11,33 +10,21 @@ interface EducationItem {
logo?: string;
}
interface EducationProps {
education: EducationItem[];
interface CertificateItem {
name: string;
issuer: string;
date: string;
credentialId: string;
}
function EduCard({ item, index }: { item: EducationItem; index: number }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => el.classList.add("visible"), index * 100);
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, [index]);
interface EducationProps {
education: EducationItem[];
certificates: CertificateItem[];
}
function EduCard({ item }: { item: EducationItem }) {
return (
<div
ref={ref}
className="reveal"
style={{
border: "1px solid #1c1f26",
padding: "1.75rem",
@@ -45,6 +32,9 @@ function EduCard({ item, index }: { item: EducationItem; index: number }) {
position: "relative",
overflow: "hidden",
transition: "border-color 0.3s",
flex: 1,
display: "flex",
flexDirection: "column",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#6b5730")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
@@ -113,26 +103,80 @@ function EduCard({ item, index }: { item: EducationItem; index: number }) {
);
}
export default function Education({ education }: EducationProps) {
const headingRef = useRef<HTMLDivElement>(null);
function CertCard({ item }: { item: CertificateItem }) {
return (
<div
style={{
border: "1px solid #1c1f26",
padding: "1.75rem",
background: "#0e1014",
position: "relative",
overflow: "hidden",
transition: "border-color 0.3s",
flex: 1,
display: "flex",
flexDirection: "column",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#c8a96e")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.12em",
color: "#c8a96e",
marginBottom: "1rem",
}}
>
{item.date}
</div>
useEffect(() => {
const el = headingRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.15rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1.3,
marginBottom: "0.5rem",
}}
>
{item.name}
</h3>
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.85rem",
color: "#6b7280",
lineHeight: 1.5,
marginBottom: "1rem",
}}
>
{item.issuer}
</p>
<div style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.55rem",
color: "#4a5060",
letterSpacing: "0.12em",
textTransform: "uppercase",
marginTop: "auto"
}}>
ID: {item.credentialId}
</div>
</div>
);
}
export default function Education({ education, certificates }: EducationProps) {
return (
<section id="education" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Academic Background</div>
<h2
className="font-display"
@@ -152,18 +196,55 @@ export default function Education({ education }: EducationProps) {
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 340px), 1fr))",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
gap: "1px",
background: "#1c1f26",
border: "1px solid #1c1f26",
}}
>
{education.map((item, i) => (
<div key={item.degree} style={{ background: "#07080a" }}>
<EduCard item={item} index={i} />
{education.map((item) => (
<div key={item.degree} style={{ background: "#07080a", display: "flex" }}>
<EduCard item={item} />
</div>
))}
</div>
{certificates && certificates.length > 0 && (
<div style={{ marginTop: "6rem" }}>
<div style={{ marginBottom: "3rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Licenses & Certifications</div>
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2rem, 4vw, 3.5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Certificates
</h3>
<div style={{ width: "32px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
gap: "1px",
background: "#1c1f26",
border: "1px solid #1c1f26",
}}
>
{certificates.map((cert) => (
<div key={cert.name} style={{ background: "#07080a", display: "flex" }}>
<CertCard item={cert} />
</div>
))}
</div>
</div>
)}
</div>
</section>
);

View File

@@ -1,8 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useState } from "react";
import SafeImage from "./SafeImage";
interface Experience {
name: string;
company: string;
@@ -25,30 +24,10 @@ function formatDate(d: string) {
}
function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => el.classList.add("visible"), index * 100);
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, [index]);
const isCurrent = exp.endDate === "current";
return (
<div
ref={ref}
className="reveal"
style={{
display: "grid",
gridTemplateColumns: "120px 1px 1fr",
@@ -146,20 +125,7 @@ function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
}
export default function Experience({ experiences }: ExperienceProps) {
const headingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = headingRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const [showAll, setShowAll] = useState(false);
return (
<section
@@ -167,7 +133,7 @@ export default function Experience({ experiences }: ExperienceProps) {
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Career Path</div>
<h2
className="font-display"
@@ -185,10 +151,52 @@ export default function Experience({ experiences }: ExperienceProps) {
</div>
<div>
{experiences.map((exp, i) => (
<ExperienceItem key={exp.name} exp={exp} index={i} />
{(showAll ? experiences : experiences.slice(0, 2)).map((exp, i) => (
<div
key={exp.name}
style={{
opacity: 1,
animation: "fadeUp 0.6s ease both",
animationDelay: `${i * 100}ms`
}}
>
<ExperienceItem exp={exp} index={i} />
</div>
))}
</div>
{experiences.length > 2 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "1rem" }}>
<button
onClick={() => setShowAll(!showAll)}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.75rem",
letterSpacing: "0.15em",
textTransform: "uppercase",
padding: "14px 28px",
background: "transparent",
border: "1px solid #1c1f26",
color: "#c8a96e",
cursor: "pointer",
transition: "all 0.3s ease",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem"
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(200, 169, 110, 0.03)";
e.currentTarget.style.borderColor = "#c8a96e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor = "#1c1f26";
}}
>
{showAll ? "— Show Less" : "+ Show More"}
</button>
</div>
)}
</div>
</section>
);

View File

@@ -39,10 +39,18 @@ export default function GridCanvas() {
window.addEventListener("resize", resize);
// ── mouse tracking (global so it works across the whole page) ────────────
const onMove = (e: MouseEvent) => { tx = e.clientX; ty = e.clientY; };
const onLeave = () => { tx = -3000; ty = -3000; };
window.addEventListener("mousemove", onMove);
document.documentElement.addEventListener("mouseleave", onLeave);
const onPointerMove = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = e.clientX; ty = e.clientY; } };
const onPointerLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = -3000; ty = -3000; } };
window.addEventListener("pointermove", onPointerMove);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
// ── touch tracking (mobile) ───────────────────────────────────────────────
const onTouch = (e: TouchEvent) => {
const t = e.touches[0];
if (t) { tx = t.clientX; ty = t.clientY; }
};
window.addEventListener("touchstart", onTouch, { passive: true });
window.addEventListener("touchmove", onTouch, { passive: true });
// ── displacement: pull a point (px,py) toward the cursor ─────────────────
const warp = (px: number, py: number): [number, number] => {
@@ -159,8 +167,10 @@ export default function GridCanvas() {
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
document.documentElement.removeEventListener("mouseleave", onLeave);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("touchstart", onTouch);
window.removeEventListener("touchmove", onTouch);
};
}, []);

View File

@@ -30,9 +30,9 @@ export default function Navigation() {
right: 0,
zIndex: 100,
transition: "background 0.4s ease, border-color 0.4s ease",
background: scrolled ? "rgba(7,8,10,0.94)" : "transparent",
backdropFilter: scrolled ? "blur(16px)" : "none",
borderBottom: scrolled ? "1px solid #1c1f26" : "1px solid transparent",
background: "rgba(7,8,10,0.94)",
backdropFilter: "blur(16px)",
borderBottom: "1px solid #1c1f26",
}}
>
<nav
@@ -127,16 +127,22 @@ export default function Navigation() {
{/* Mobile toggle */}
<button
onClick={() => setOpen(!open)}
className="md:hidden flex flex-col"
type="button"
onClick={() => setOpen((o) => !o)}
className="mobile-menu-btn"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "8px",
padding: "12px",
display: "flex",
flexDirection: "column",
gap: "5px",
touchAction: "manipulation",
}}
aria-label="Toggle menu"
aria-expanded={open}
aria-controls="mobile-nav-menu"
>
<span
style={{
@@ -171,13 +177,9 @@ export default function Navigation() {
</button>
</nav>
{/* Hover underline via CSS */}
<style>{`
nav a:hover .nav-underline { transform: scaleX(1) !important; }
`}</style>
{/* Mobile menu */}
<div
id="mobile-nav-menu"
style={{
background: "rgba(7,8,10,0.98)",
borderTop: "1px solid #1c1f26",

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import SafeImage from "./SafeImage";
interface Task {
@@ -27,248 +27,280 @@ interface ProjectsProps {
projects: Project[];
}
function ProjectCard({ project, index }: { project: Project; index: number }) {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => el.classList.add("visible"), index * 80);
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, [index]);
function ProjectRow({
project,
index,
isOpen,
onToggle
}: {
project: Project;
index: number;
isOpen: boolean;
onToggle: () => void;
}) {
const allTechs = Array.from(
new Set(project.tasks.flatMap((t) => t.technologies))
);
const doneCount = project.tasks.filter((t) => t.status === "Done").length;
return (
<div
ref={ref}
className="project-card reveal"
style={{ borderRadius: 0, padding: "2rem", flex: 1, display: "flex", flexDirection: "column", position: "relative", zIndex: 1 }}
<div
className="project-row"
style={{
borderBottom: "1px solid #1c1f26",
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.25rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
{project.logo && (
<SafeImage
src={project.logo}
alt={project.name}
width={40}
height={40}
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
style={{ width: "28px", height: "28px", objectFit: "contain", filter: "brightness(0.85)" }}
containerStyle={{ background: "#0a0b0e" }}
/>
)}
<div>
<div
className="section-label"
style={{ fontSize: "0.58rem", marginBottom: "0.25rem" }}
>
{String(index + 1).padStart(2, "0")}
</div>
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.6rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
}}
>
{project.name}
</h3>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", justifyContent: "flex-end" }}>
{project.links?.map((link) => (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.6rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
padding: "3px 10px",
border: "1px solid #1c1f26",
color: "#4a5060",
textDecoration: "none",
transition: "border-color 0.2s, color 0.2s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
(e.currentTarget as HTMLElement).style.color = "#c8a96e";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#1c1f26";
(e.currentTarget as HTMLElement).style.color = "#4a5060";
}}
>
{link.name}
</a>
))}
</div>
</div>
{/* Description */}
<p
{/* Header bar (always visible) */}
<button
onClick={onToggle}
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
lineHeight: 1.7,
color: "#6b7280",
marginBottom: "1.5rem",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "2rem 0",
background: "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget.querySelector(".project-title") as HTMLElement).style.color = "#c8a96e";
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.transform = isOpen ? "rotate(180deg) scale(1.1)" : "scale(1.1)";
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.color = "#c8a96e";
}}
onMouseLeave={(e) => {
(e.currentTarget.querySelector(".project-title") as HTMLElement).style.color = isOpen ? "#c8a96e" : "#e2e4e9";
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.transform = isOpen ? "rotate(180deg) scale(1)" : "scale(1)";
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.color = "#4a5060";
}}
>
{project.description}
</p>
{/* Tech tags */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
{allTechs.slice(0, 8).map((tech) => (
<span key={tech} className="tech-tag">{tech}</span>
))}
{allTechs.length > 8 && (
<span className="tech-tag" style={{ color: "#c8a96e", borderColor: "#6b5730" }}>
+{allTechs.length - 8}
<div style={{ display: "flex", alignItems: "baseline", gap: "2rem" }}>
<span
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.85rem",
color: "#4a5060",
letterSpacing: "0.1em",
}}
>
{String(index + 1).padStart(2, "0")}
</span>
)}
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "auto", paddingTop: "1.5rem" }}>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
color: "#3a7a5a",
letterSpacing: "0.08em",
}}
>
{doneCount}/{project.tasks.length} tasks complete
<h3
className="project-title font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2rem, 5vw, 4rem)",
letterSpacing: "0.02em",
color: isOpen ? "#c8a96e" : "#e2e4e9",
transition: "color 0.3s ease",
margin: 0,
lineHeight: 1,
}}
>
{project.name}
</h3>
</div>
<button
onClick={() => setExpanded(!expanded)}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
background: "none",
border: "none",
cursor: "pointer",
color: "#c8a96e",
padding: 0,
transition: "opacity 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.7")}
onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
>
{expanded ? "— Hide tasks" : "+ Show tasks"}
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "2rem" }}>
<div
className="toggle-icon"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "40px",
height: "40px",
borderRadius: "50%",
border: "1px solid #1c1f26",
color: "#4a5060",
transition: "all 0.3s ease",
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
</button>
{/* Tasks */}
{expanded && (
<div style={{ marginTop: "1.5rem", borderTop: "1px solid #1c1f26", paddingTop: "1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{project.tasks.map((task) => (
<div
key={task.name}
style={{
display: "flex",
gap: "1rem",
alignItems: "flex-start",
}}
>
<div
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
marginTop: "6px",
flexShrink: 0,
}}
/>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
<span
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.78rem",
color: "#e2e4e9",
letterSpacing: "0.04em",
}}
>
{task.name}
</span>
<span className={task.status === "Done" ? "status-done" : "status-progress"}>
{task.status}
</span>
{/* Expandable Content */}
<div
style={{
display: "grid",
gridTemplateRows: isOpen ? "1fr" : "0fr",
transition: "grid-template-rows 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div style={{ overflow: "hidden" }}>
<div style={{ paddingBottom: "3rem", paddingTop: "0.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "3rem" }}>
{/* Top part: Description & Links & Techs */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "3rem", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ flex: "1 1 500px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.5rem" }}>
{project.logo && (
<SafeImage
src={project.logo}
alt={project.name}
width={48}
height={48}
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
style={{ width: "36px", height: "36px", objectFit: "contain", filter: "brightness(0.9)" }}
/>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{project.links?.map((link) => (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.65rem",
letterSpacing: "0.1em",
textTransform: "uppercase",
padding: "6px 14px",
border: "1px solid #1c1f26",
color: "#c8a96e",
textDecoration: "none",
transition: "all 0.2s",
borderRadius: "2px",
background: "rgba(200, 169, 110, 0.03)"
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(200, 169, 110, 0.1)";
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(200, 169, 110, 0.03)";
(e.currentTarget as HTMLElement).style.borderColor = "#1c1f26";
}}
>
{link.name}
</a>
))}
</div>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", color: "#4a5060", lineHeight: 1.6, margin: 0 }}>
{task.description}
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "1.05rem",
lineHeight: 1.8,
color: "#9ca3af",
maxWidth: "700px",
}}
>
{project.description}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem", marginTop: "0.5rem" }}>
{task.technologies.map((tech) => (
<span key={tech} className="tech-tag" style={{ fontSize: "0.58rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "2rem" }}>
{allTechs.map((tech) => (
<span key={tech} className="tech-tag" style={{ border: "1px solid #2a2e38", background: "#0e1014" }}>
{tech}
</span>
))}
</div>
</div>
<div style={{ flex: "0 0 auto", pointerEvents: "none" }}>
<div style={{ display: "inline-flex", flexDirection: "column", padding: "1.5rem", background: "#0e1014", border: "1px solid #1c1f26", borderRadius: "8px" }}>
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.6rem", color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.15em", marginBottom: "0.5rem" }}>
Development Status
</span>
<span style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "2.5rem", color: "#e2e4e9", lineHeight: 1 }}>
{doneCount}<span style={{ color: "#4a5060" }}>/{project.tasks.length}</span>
</span>
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.7rem", color: "#3a7a5a", marginTop: "0.2rem" }}>
Tasks Completed
</span>
</div>
</div>
</div>
))}
{/* Bottom part: Tasks Grid */}
<div style={{ marginTop: "1rem" }}>
<h4 style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.75rem", letterSpacing: "0.15em", color: "#6b7280", textTransform: "uppercase", marginBottom: "1.5rem", borderBottom: "1px solid #1c1f26", paddingBottom: "1rem" }}>
Project Milestones
</h4>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: "2rem",
}}
>
{project.tasks.map((task) => (
<div
key={task.name}
style={{
display: "flex",
gap: "1rem",
alignItems: "flex-start",
}}
>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
marginTop: "6px",
flexShrink: 0,
boxShadow: task.status === "Done" ? "0 0 10px rgba(58,122,90,0.5)" : "0 0 10px rgba(200,149,74,0.3)",
}}
/>
<div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<span
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.85rem",
color: "#e2e4e9",
letterSpacing: "0.02em",
fontWeight: 500,
}}
>
{task.name}
</span>
<span className={task.status === "Done" ? "status-done" : "status-progress"} style={{ fontSize: "0.5rem", padding: "3px 6px" }}>
{task.status}
</span>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.85rem", color: "#6b7280", lineHeight: 1.6, margin: 0 }}>
{task.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function Projects({ projects }: ProjectsProps) {
const headingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = headingRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add("visible");
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<section id="projects" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<section id="projects" style={{ padding: "8rem 0" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem" }}>
{/* Section header */}
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div style={{ marginBottom: "6rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
Selected Work
</div>
@@ -300,20 +332,16 @@ export default function Projects({ projects }: ProjectsProps) {
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
{/* Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 520px), 1fr))",
gap: "1.5px",
background: "#1c1f26",
border: "1px solid #1c1f26",
}}
>
{/* List */}
<div style={{ borderTop: "1px solid #1c1f26" }}>
{projects.map((project, i) => (
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
<ProjectCard project={project} index={i} />
</div>
<ProjectRow
key={project.name}
project={project}
index={i}
isOpen={openIndex === i}
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
/>
))}
</div>
</div>

View File

@@ -1,7 +1,5 @@
"use client";
import { useEffect, useRef } from "react";
interface SkillsProps {
skills: string[];
languages: { name: string; level: string }[];
@@ -9,25 +7,6 @@ interface SkillsProps {
}
export default function Skills({ skills, languages, interests }: SkillsProps) {
const headingRef = useRef<HTMLDivElement>(null);
const skillsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const els = [headingRef.current, skillsRef.current].filter(Boolean);
const obs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
}
});
},
{ threshold: 0.1 }
);
els.forEach((el) => el && obs.observe(el));
return () => obs.disconnect();
}, []);
// Give skills a pseudo-random "weight" based on their index for visual variety
const weights = [1.3, 1, 1.5, 1, 1.2, 0.9, 1.4, 1, 1.1, 0.95, 1.3, 1, 1.2, 0.9, 1.4, 1, 1.1, 1.3];
@@ -44,7 +23,7 @@ export default function Skills({ skills, languages, interests }: SkillsProps) {
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Expertise</div>
<h2
className="font-display"
@@ -69,7 +48,7 @@ export default function Skills({ skills, languages, interests }: SkillsProps) {
}}
>
{/* Tech skills */}
<div ref={skillsRef} className="reveal">
<div>
<div
className="font-mono"
style={{