Implement achraf's portfolio

This commit is contained in:
achraf
2026-03-31 00:28:00 +02:00
parent fcd8d3d986
commit a4ee12732d
20 changed files with 2734 additions and 87 deletions

300
components/Contact.tsx Normal file
View File

@@ -0,0 +1,300 @@
"use client";
import { useEffect, useRef } from "react";
import SafeImage from "./SafeImage";
interface ContactInfo {
email: string;
phone: string;
linkedin: string;
github: string;
Address: string;
}
interface ContactProps {
contact: ContactInfo;
fullName: string;
image?: string;
}
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",
value: contact.email,
href: `mailto:${contact.email}`,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
},
{
label: "Phone",
value: contact.phone,
href: `tel:${contact.phone}`,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.36 2 2 0 0 1 3.6 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.6a16 16 0 0 0 6 6l.91-.9a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
),
},
{
label: "LinkedIn",
value: "achraf-achkari",
href: contact.linkedin,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</svg>
),
},
{
label: "GitHub",
value: "achachraf",
href: contact.github,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
</svg>
),
},
];
return (
<section id="contact" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={ref} className="reveal">
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Let&apos;s Connect</div>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
marginBottom: "1rem",
}}
>
Contact
</h2>
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginBottom: "4rem" }} />
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))",
gap: "3rem",
alignItems: "start",
}}
>
{/* Left: profile */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem", marginBottom: "2rem" }}>
{image && (
<SafeImage
src={image}
alt={fullName}
width={72}
height={72}
fallbackLabel={fullName.split(" ").map((n) => n[0]).join("")}
style={{ width: "72px", height: "72px", objectFit: "cover", filter: "grayscale(0.3)" }}
containerStyle={{
border: "1px solid #1c1f26",
fontSize: "1.2rem",
}}
/>
)}
<div>
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.8rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
{fullName}
</h3>
<div
className="font-mono section-label"
style={{ fontSize: "0.6rem", marginTop: "0.25rem" }}
>
Technical Lead · Kereval
</div>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "2rem",
paddingBottom: "2rem",
borderBottom: "1px solid #1c1f26",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#4a5060" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.65rem",
letterSpacing: "0.1em",
color: "#4a5060",
}}
>
{contact.Address}
</span>
</div>
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
lineHeight: 1.8,
color: "#6b7280",
}}
>
Open to new opportunities, collaborations, and interesting
conversations. Drop a line I&apos;d love to hear from you.
</p>
</div>
{/* Right: links */}
<div style={{ display: "flex", flexDirection: "column", gap: "0" }}>
{links.map((link, i) => (
<a
key={link.label}
href={link.href}
target={link.label !== "Email" && link.label !== "Phone" ? "_blank" : undefined}
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderTop: i === 0 ? "1px solid #1c1f26" : "none",
borderLeft: "1px solid #1c1f26",
borderRight: "1px solid #1c1f26",
borderBottom: "1px solid #1c1f26",
textDecoration: "none",
color: "#6b7280",
background: "#0e1014",
transition: "background 0.2s, color 0.2s, border-color 0.2s",
gap: "1rem",
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.background = "rgba(200,169,110,0.04)";
el.style.color = "#c8a96e";
el.style.borderLeftColor = "#c8a96e";
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.background = "#0e1014";
el.style.color = "#6b7280";
el.style.borderLeftColor = "#1c1f26";
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{link.icon}
<div>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.58rem",
letterSpacing: "0.14em",
textTransform: "uppercase",
marginBottom: "0.15rem",
color: "inherit",
opacity: 0.6,
}}
>
{link.label}
</div>
<div
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
color: "inherit",
}}
>
{link.value}
</div>
</div>
</div>
<span style={{ fontSize: "1rem", opacity: 0.5 }}></span>
</a>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div
style={{
maxWidth: "1200px",
margin: "6rem auto 0",
paddingTop: "2rem",
borderTop: "1px solid #1c1f26",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.1em",
color: "#4a5060",
}}
>
© {new Date().getFullYear()} {fullName}. All rights reserved.
</div>
<div
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.2rem",
letterSpacing: "0.12em",
color: "#1c1f26",
}}
>
ACHRAF ACHKARI
</div>
</div>
</section>
);
}

170
components/Education.tsx Normal file
View File

@@ -0,0 +1,170 @@
"use client";
import { useEffect, useRef } from "react";
import SafeImage from "./SafeImage";
interface EducationItem {
degree: string;
school: string;
startDate: string;
endDate: string;
logo?: string;
}
interface EducationProps {
education: EducationItem[];
}
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]);
return (
<div
ref={ref}
className="reveal"
style={{
border: "1px solid #1c1f26",
padding: "1.75rem",
background: "#0e1014",
position: "relative",
overflow: "hidden",
transition: "border-color 0.3s",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#6b5730")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
>
{/* Corner accent */}
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: "40px",
height: "40px",
borderLeft: "1px solid #1c1f26",
borderBottom: "1px solid #1c1f26",
}}
/>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.12em",
color: "#c8a96e",
marginBottom: "1rem",
}}
>
{item.startDate} {item.endDate}
</div>
{item.logo && (
<div style={{ marginBottom: "1rem" }}>
<SafeImage
src={item.logo}
alt={item.school}
height={32}
style={{ height: "32px", objectFit: "contain", filter: "brightness(0.6) grayscale(0.5)" }}
/>
</div>
)}
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.3rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1.2,
marginBottom: "0.5rem",
}}
>
{item.degree}
</h3>
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.85rem",
color: "#6b7280",
lineHeight: 1.5,
}}
>
{item.school}
</p>
</div>
);
}
export default function Education({ education }: EducationProps) {
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();
}, []);
return (
<section id="education" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Academic Background</div>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Education
</h2>
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, 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} />
</div>
))}
</div>
</div>
</section>
);
}

195
components/Experience.tsx Normal file
View File

@@ -0,0 +1,195 @@
"use client";
import { useEffect, useRef } from "react";
import SafeImage from "./SafeImage";
interface Experience {
name: string;
company: string;
position: string;
description: string;
startDate: string;
endDate: string;
logo?: string;
}
interface ExperienceProps {
experiences: Experience[];
}
function formatDate(d: string) {
if (d === "current") return "Present";
const [, m, y] = d.split("-");
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[parseInt(m) - 1]} ${y}`;
}
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",
gap: "0 2rem",
paddingBottom: "3rem",
}}
>
{/* Date */}
<div style={{ textAlign: "right", paddingTop: "2px" }}>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.08em",
color: isCurrent ? "#c8a96e" : "#4a5060",
lineHeight: 1.6,
}}
>
{isCurrent && (
<div style={{ color: "#c8a96e", marginBottom: "0.25rem", letterSpacing: "0.14em" }}>
NOW
</div>
)}
{formatDate(exp.startDate)}
<br /><br />
{formatDate(exp.endDate)}
</div>
</div>
{/* Line with dot */}
<div style={{ position: "relative", display: "flex", justifyContent: "center" }}>
<div style={{ width: "1px", background: "#1c1f26", height: "100%" }} />
<div
style={{
position: "absolute",
top: "4px",
width: "8px",
height: "8px",
borderRadius: "50%",
background: isCurrent ? "#c8a96e" : "#1c1f26",
border: isCurrent ? "none" : "1px solid #4a5060",
left: "50%",
transform: "translateX(-50%)",
}}
/>
</div>
{/* Content */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
{exp.logo && (
<SafeImage
src={exp.logo}
alt={exp.company}
width={28}
height={28}
fallbackLabel={exp.company.slice(0, 2).toUpperCase()}
style={{ width: "24px", height: "24px", objectFit: "contain", filter: "brightness(0.7) grayscale(0.4)" }}
/>
)}
<span
className="section-label"
style={{ fontSize: "0.58rem" }}
>
{exp.company}
</span>
</div>
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.5rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
marginBottom: "0.5rem",
}}
>
{exp.position}
</h3>
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
lineHeight: 1.7,
color: "#6b7280",
}}
>
{exp.description}
</p>
</div>
</div>
);
}
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();
}, []);
return (
<section
id="experience"
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Career Path</div>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Experience
</h2>
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
<div>
{experiences.map((exp, i) => (
<ExperienceItem key={exp.name} exp={exp} index={i} />
))}
</div>
</div>
</section>
);
}

181
components/GridCanvas.tsx Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Full-page fixed canvas that draws a distorted grid background.
* The grid warps toward the mouse cursor like a gravitational lens /
* spacetime distortion — grid lines curve inward, intersections glow,
* and a soft mass-glow follows the cursor.
*/
export default function GridCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf: number;
// Smoothed mouse position (lerped toward target)
let mx = -3000;
let my = -3000;
// Raw target from events
let tx = -3000;
let ty = -3000;
const GRID = 64; // px between grid lines
const SIGMA = 190; // radius of distortion effect (px)
const STRENGTH = 52; // max pixel pull toward cursor
// ── resize ───────────────────────────────────────────────────────────────
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
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);
// ── displacement: pull a point (px,py) toward the cursor ─────────────────
const warp = (px: number, py: number): [number, number] => {
const dx = px - mx;
const dy = py - my;
const d2 = dx * dx + dy * dy;
if (d2 < 1) return [px, py];
const d = Math.sqrt(d2);
const f = STRENGTH * Math.exp(-d2 / (2 * SIGMA * SIGMA));
return [px - (dx / d) * f, py - (dy / d) * f];
};
// ── main draw loop ────────────────────────────────────────────────────────
const draw = () => {
// Smooth-lerp mouse position (feels weighty / spacetime-like)
mx += (tx - mx) * 0.065;
my += (ty - my) * 0.065;
const W = canvas.width;
const H = canvas.height;
const active = mx > -2000;
ctx.clearRect(0, 0, W, H);
// ── horizontal grid lines ─────────────────────────────────────────────
for (let gy = 0; gy <= H + GRID; gy += GRID) {
const lineDist = active ? Math.abs(gy - my) : 9999;
const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0;
ctx.beginPath();
let first = true;
for (let x = 0; x <= W; x += 5) {
const [wx, wy] = active ? warp(x, gy) : [x, gy];
first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
first = false;
}
ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`;
ctx.lineWidth = 0.5 + lift * 0.55;
ctx.stroke();
}
// ── vertical grid lines ───────────────────────────────────────────────
for (let gx = 0; gx <= W + GRID; gx += GRID) {
const lineDist = active ? Math.abs(gx - mx) : 9999;
const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0;
ctx.beginPath();
let first = true;
for (let y = 0; y <= H; y += 5) {
const [wx, wy] = active ? warp(gx, y) : [gx, y];
first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
first = false;
}
ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`;
ctx.lineWidth = 0.5 + lift * 0.55;
ctx.stroke();
}
// ── intersection dots (lensed stars) ─────────────────────────────────
if (active) {
const cullR2 = (SIGMA * 2.2) * (SIGMA * 2.2);
for (let gx = 0; gx <= W + GRID; gx += GRID) {
for (let gy = 0; gy <= H + GRID; gy += GRID) {
const dx = gx - mx;
const dy = gy - my;
const d2 = dx * dx + dy * dy;
if (d2 > cullR2) continue;
const [wx, wy] = warp(gx, gy);
const intensity = Math.exp(-d2 / (2 * SIGMA * SIGMA * 0.35));
// outer halo
ctx.beginPath();
ctx.arc(wx, wy, 1.5 + intensity * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,169,110,${0.1 + intensity * 0.5})`;
ctx.fill();
// bright core for very close intersections
if (intensity > 0.4) {
ctx.beginPath();
ctx.arc(wx, wy, 0.8, 0, Math.PI * 2);
ctx.fillStyle = `rgba(232,200,135,${intensity * 0.7})`;
ctx.fill();
}
}
}
}
// ── gravitational mass glow at cursor ─────────────────────────────────
if (active) {
// wide soft halo
const halo = ctx.createRadialGradient(mx, my, 0, mx, my, SIGMA * 0.85);
halo.addColorStop(0, "rgba(200,169,110,0.07)");
halo.addColorStop(0.5, "rgba(200,169,110,0.025)");
halo.addColorStop(1, "rgba(200,169,110,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(mx, my, SIGMA * 0.85, 0, Math.PI * 2);
ctx.fill();
// tight bright core (the "mass")
const core = ctx.createRadialGradient(mx, my, 0, mx, my, 22);
core.addColorStop(0, "rgba(240,210,150,0.22)");
core.addColorStop(1, "rgba(200,169,110,0)");
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(mx, my, 22, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
document.documentElement.removeEventListener("mouseleave", onLeave);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 0,
}}
/>
);
}

413
components/Hero.tsx Normal file
View File

@@ -0,0 +1,413 @@
"use client";
import SafeImage from "./SafeImage";
interface Task {
name: string;
description: string;
status: string;
technologies: string[];
}
interface ProjectLink {
name: string;
url: string;
}
interface Project {
name: string;
description: string;
logo?: string;
links?: ProjectLink[];
tasks: Task[];
}
interface Experience {
company: string;
position: string;
startDate: string;
endDate: string;
logo?: string;
}
interface Education {
degree: string;
school: string;
startDate: string;
endDate: string;
}
interface HeroProps {
name: string;
lastName: string;
title: string;
description: string;
skills: string[];
projects: Project[];
experiences: Experience[];
education: Education[];
languages: { name: string; level: string }[];
interests: string[];
location: string;
}
// ─── BentoCard ───────────────────────────────────────────────────────────────
function BentoCard({
num,
label,
href,
children,
accent = false,
delay = 0,
}: {
num: string;
label: string;
href: string;
children: React.ReactNode;
accent?: boolean;
delay?: number;
}) {
return (
<a
href={href}
style={{
animationName: "fadeUp",
animationDuration: "0.7s",
animationTimingFunction: "ease",
animationFillMode: "both",
animationDelay: `${delay}ms`,
display: "flex",
flexDirection: "column",
padding: "1.75rem",
border: "1px solid #1c1f26",
background: accent ? "rgba(200,169,110,0.03)" : "#0e1014",
textDecoration: "none",
color: "inherit",
position: "relative",
overflow: "hidden",
cursor: "pointer",
transition: "border-color 0.3s ease, background 0.3s ease, transform 0.3s ease",
minHeight: "200px",
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#6b5730";
el.style.background = accent ? "rgba(200,169,110,0.07)" : "rgba(200,169,110,0.03)";
el.style.transform = "translateY(-2px)";
const arrow = el.querySelector(".card-arrow") as HTMLElement | null;
if (arrow) arrow.style.opacity = "1";
const shine = el.querySelector(".card-shine") as HTMLElement | null;
if (shine) shine.style.opacity = "1";
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#1c1f26";
el.style.background = accent ? "rgba(200,169,110,0.03)" : "#0e1014";
el.style.transform = "translateY(0)";
const arrow = el.querySelector(".card-arrow") as HTMLElement | null;
if (arrow) arrow.style.opacity = "0";
const shine = el.querySelector(".card-shine") as HTMLElement | null;
if (shine) shine.style.opacity = "0";
}}
>
<div
className="card-shine"
style={{
position: "absolute",
inset: 0,
background: "linear-gradient(135deg, rgba(200,169,110,0.06) 0%, transparent 50%)",
opacity: 0,
transition: "opacity 0.3s",
pointerEvents: "none",
}}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "auto" }}>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.58rem", letterSpacing: "0.2em", color: "#c8a96e", opacity: 0.7 }}>
{num} · {label}
</div>
<div className="card-arrow" style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.75rem", color: "#c8a96e", opacity: 0, transition: "opacity 0.2s" }}>
</div>
</div>
<div style={{ marginTop: "1rem", flex: 1 }}>{children}</div>
</a>
);
}
// ─── Hero ─────────────────────────────────────────────────────────────────────
export default function Hero({
name,
lastName,
title,
description,
skills,
projects,
experiences,
education,
languages,
interests,
location,
}: HeroProps) {
const feat1 = projects[2]; // FOON
const feat2 = projects[0]; // Gazelle
const topSkills = skills.slice(0, 10);
const levelColor = (level: string) => {
if (level === "Native") return "#c8a96e";
if (level === "C2") return "#3a7a5a";
return "#4a6050";
};
return (
<section
id="hero"
className="hero-section"
style={{ minHeight: "100vh", position: "relative" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto", position: "relative", zIndex: 3 }}>
{/* ── Headline ─────────────────────────────────────────────────── */}
<div
className="animate-fade-up delay-1"
style={{ marginBottom: "2.5rem" }}
>
<h1
style={{
fontFamily: "var(--font-lora), Georgia, serif",
fontStyle: "italic",
fontWeight: 400,
fontSize: "clamp(2.6rem, 7vw, 7rem)",
lineHeight: 1.05,
letterSpacing: "-0.01em",
color: "#e2e4e9",
margin: 0,
userSelect: "none",
}}
>
Let&apos;s bend
<br />
<span style={{ color: "#c8a96e" }}>spacetime</span>
</h1>
</div>
{/* ── Name + description strip ──────────────────────────────── */}
<div className="hero-header-strip animate-fade-up delay-2">
<div>
<div
className="section-label"
style={{ marginBottom: "0.5rem", display: "flex", alignItems: "center", gap: "0.6rem" }}
>
<span style={{ display: "inline-block", width: "24px", height: "1px", background: "#c8a96e" }} />
{title}
</div>
<div
style={{
fontFamily: "var(--font-bebas), Impact, sans-serif",
fontSize: "clamp(1.6rem, 4vw, 3rem)",
letterSpacing: "0.04em",
color: "#6b7280",
lineHeight: 1,
}}
>
{name}{" "}
<span style={{ color: "#4a5060" }}>{lastName}</span>
</div>
</div>
<div style={{ maxWidth: "360px" }}>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.88rem", lineHeight: 1.75, color: "#6b7280", margin: 0 }}>
{description}
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.6rem" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#4a5060" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.62rem", letterSpacing: "0.1em", color: "#4a5060" }}>
{location}
</span>
</div>
</div>
</div>
{/* ── Divider ───────────────────────────────────────────────── */}
<div
className="animate-fade-up delay-3"
style={{ height: "1px", background: "#1c1f26", marginBottom: "2rem" }}
/>
{/* ── 3 × 2 Bento grid ──────────────────────────────────────── */}
<div className="hero-bento-grid">
{/* 01 — FOON */}
<BentoCard num="01" label="TypeScript SDK" href="#projects" accent delay={500}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
{feat1.logo && (
<SafeImage src={feat1.logo} alt={feat1.name} width={28} height={28}
style={{ width: "22px", height: "22px", objectFit: "contain", filter: "brightness(0.9)" }}
/>
)}
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1 }}>
{feat1.name}
</h3>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", lineHeight: 1.65, color: "#6b7280", marginBottom: "1rem" }}>
{feat1.description}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem", marginBottom: "0.75rem" }}>
{feat1.tasks.flatMap((t) => t.technologies).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).map((tech) => (
<span key={tech} className="tech-tag">{tech}</span>
))}
</div>
{feat1.links && (
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap", marginTop: "auto" }}>
{feat1.links.slice(0, 2).map((l) => (
<span key={l.name} style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.6rem", letterSpacing: "0.1em", color: "#c8a96e", textTransform: "uppercase" }}>
{l.name}
</span>
))}
</div>
)}
</BentoCard>
{/* 02 — Gazelle */}
<BentoCard num="02" label="Java · Healthcare" href="#projects" delay={600}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
{feat2.logo && (
<SafeImage src={feat2.logo} alt={feat2.name} width={28} height={28}
fallbackLabel={feat2.name.slice(0, 2).toUpperCase()}
style={{ width: "22px", height: "22px", objectFit: "contain", filter: "brightness(0.75) grayscale(0.3)" }}
/>
)}
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1 }}>
{feat2.name}
</h3>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", lineHeight: 1.65, color: "#6b7280", marginBottom: "1rem" }}>
{feat2.description}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
{feat2.tasks.map((task) => (
<div key={task.name} style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<span style={{ width: "5px", height: "5px", borderRadius: "50%", background: task.status === "Done" ? "#3a7a5a" : "#c8954a", flexShrink: 0 }} />
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.62rem", color: "#6b7280", letterSpacing: "0.04em" }}>
{task.name}
</span>
<span className={task.status === "Done" ? "status-done" : "status-progress"}>{task.status}</span>
</div>
))}
</div>
</BentoCard>
{/* 03 — Experience */}
<BentoCard num="03" label="Career" href="#experience" delay={700}>
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1, marginBottom: "1rem" }}>
Experience
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.65rem" }}>
{experiences.map((exp, i) => (
<div key={exp.company + exp.position} style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", opacity: i === 0 ? 1 : i === 1 ? 0.6 : 0.35 }}>
<div style={{ width: "6px", height: "6px", borderRadius: "50%", background: i === 0 ? "#c8a96e" : "#1c1f26", border: i === 0 ? "none" : "1px solid #4a5060", marginTop: "5px", flexShrink: 0 }} />
<div>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.7rem", color: i === 0 ? "#e2e4e9" : "#6b7280", letterSpacing: "0.04em", lineHeight: 1.3 }}>
{exp.position}
</div>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.58rem", letterSpacing: "0.12em", color: i === 0 ? "#c8a96e" : "#4a5060", textTransform: "uppercase", marginTop: "1px" }}>
{exp.company} · {exp.endDate === "current" ? "Present" : exp.endDate.split("-")[2]}
</div>
</div>
</div>
))}
</div>
</BentoCard>
{/* 04 — Education */}
<BentoCard num="04" label="Academic" href="#education" delay={800}>
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1, marginBottom: "1rem" }}>
Education
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
{education.map((edu, i) => (
<div key={edu.degree} style={{ borderLeft: "2px solid #1c1f26", paddingLeft: "0.75rem" }}>
<div style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "0.88rem", letterSpacing: "0.06em", color: i === 0 ? "#e2e4e9" : "#6b7280", lineHeight: 1.3, marginBottom: "0.2rem" }}>
{edu.degree}
</div>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.58rem", letterSpacing: "0.1em", color: "#4a5060", textTransform: "uppercase" }}>
{edu.school.split(" ").slice(-2).join(" ")} · {edu.endDate}
</div>
</div>
))}
</div>
</BentoCard>
{/* 05 — Skills */}
<BentoCard num="05" label="Stack" href="#skills" delay={900}>
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1, marginBottom: "1rem" }}>
{skills.length} Skills
</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{topSkills.map((skill, i) => (
<span key={skill} className="tech-tag" style={{ opacity: 1 - i * 0.06, fontSize: i < 3 ? "0.68rem" : "0.6rem" }}>
{skill}
</span>
))}
{skills.length > 10 && (
<span className="tech-tag" style={{ color: "#c8a96e", borderColor: "#6b5730" }}>
+{skills.length - 10}
</span>
)}
</div>
</BentoCard>
{/* 06 — About */}
<BentoCard num="06" label="Profile" href="#contact" accent delay={1000}>
<h3 style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "1.8rem", letterSpacing: "0.04em", color: "#e2e4e9", lineHeight: 1, marginBottom: "1rem" }}>
About
</h3>
<div style={{ marginBottom: "0.85rem" }}>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.56rem", letterSpacing: "0.18em", color: "#4a5060", textTransform: "uppercase", marginBottom: "0.45rem" }}>
Languages
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{languages.map((lang) => (
<span key={lang.name} style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.62rem", padding: "2px 8px", border: `1px solid ${levelColor(lang.level)}`, color: levelColor(lang.level), letterSpacing: "0.06em" }}>
{lang.name} {lang.level}
</span>
))}
</div>
</div>
<div>
<div style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.56rem", letterSpacing: "0.18em", color: "#4a5060", textTransform: "uppercase", marginBottom: "0.45rem" }}>
Interests
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{interests.map((interest, i) => (
<span key={interest} style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.78rem", fontStyle: "italic", color: "#6b7280" }}>
{interest}{i < interests.length - 1 && <span style={{ color: "#1c1f26", marginLeft: "0.35rem" }}>·</span>}
</span>
))}
</div>
</div>
</BentoCard>
</div>
{/* ── Scroll hint ───────────────────────────────────────────── */}
<div
className="animate-fade-up delay-6"
style={{ marginTop: "2rem", display: "flex", alignItems: "center", justifyContent: "center", gap: "0.75rem" }}
>
<div style={{ height: "1px", width: "40px", background: "#1c1f26" }} />
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.58rem", letterSpacing: "0.2em", color: "#4a5060", textTransform: "uppercase" }}>
Scroll to explore
</span>
<div style={{ height: "1px", width: "40px", background: "#1c1f26" }} />
</div>
</div>
</section>
);
}

219
components/Navigation.tsx Normal file
View File

@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
const links = [
{ label: "Projects", href: "#projects" },
{ label: "Experience", href: "#experience" },
{ label: "Education", href: "#education" },
{ label: "Skills", href: "#skills" },
{ label: "Contact", href: "#contact" },
];
export default function Navigation() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState("");
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
style={{
position: "fixed",
top: 0,
left: 0,
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",
}}
>
<nav
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "0 2.5rem",
height: "68px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{/* Logo */}
<a href="#hero" style={{ textDecoration: "none", flexShrink: 0 }}>
<div
style={{
width: "38px",
height: "38px",
border: "1px solid #c8a96e",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.15rem",
color: "#c8a96e",
letterSpacing: "0.04em",
transition: "background 0.2s, box-shadow 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(200,169,110,0.1)";
e.currentTarget.style.boxShadow = "0 0 16px rgba(200,169,110,0.15)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.boxShadow = "none";
}}
>
AA
</div>
</a>
{/* Desktop links — each wrapped for padding & underline */}
<div
className="hidden md:flex"
style={{ alignItems: "center", gap: "0" }}
>
{links.map((l, i) => (
<a
key={l.href}
href={l.href}
onClick={() => setActive(l.href)}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.72rem",
letterSpacing: "0.16em",
textTransform: "uppercase",
textDecoration: "none",
padding: "0.5rem 1.4rem",
color: active === l.href ? "#c8a96e" : "#4a5060",
position: "relative",
transition: "color 0.2s",
borderLeft: i > 0 ? "1px solid #1c1f26" : "none",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = "#c8a96e";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color =
active === l.href ? "#c8a96e" : "#4a5060";
}}
>
{l.label}
{/* bottom accent line on hover */}
<span
style={{
position: "absolute",
bottom: 0,
left: "1.4rem",
right: "1.4rem",
height: "1px",
background: "#c8a96e",
transform: "scaleX(0)",
transformOrigin: "left",
transition: "transform 0.25s ease",
}}
className="nav-underline"
/>
</a>
))}
</div>
{/* Mobile toggle */}
<button
onClick={() => setOpen(!open)}
className="md:hidden flex flex-col"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "8px",
gap: "5px",
}}
aria-label="Toggle menu"
>
<span
style={{
display: "block",
width: "22px",
height: "1px",
background: "#c8a96e",
transition: "transform 0.25s ease",
transform: open ? "translateY(6px) rotate(45deg)" : "none",
}}
/>
<span
style={{
display: "block",
width: "22px",
height: "1px",
background: "#c8a96e",
transition: "opacity 0.2s",
opacity: open ? 0 : 1,
}}
/>
<span
style={{
display: "block",
width: "22px",
height: "1px",
background: "#c8a96e",
transition: "transform 0.25s ease",
transform: open ? "translateY(-6px) rotate(-45deg)" : "none",
}}
/>
</button>
</nav>
{/* Hover underline via CSS */}
<style>{`
nav a:hover .nav-underline { transform: scaleX(1) !important; }
`}</style>
{/* Mobile menu */}
<div
style={{
background: "rgba(7,8,10,0.98)",
borderTop: "1px solid #1c1f26",
overflow: "hidden",
maxHeight: open ? "320px" : "0",
transition: "max-height 0.3s ease",
}}
>
<div style={{ padding: "1.5rem 2.5rem", display: "flex", flexDirection: "column", gap: "0" }}>
{links.map((l, i) => (
<a
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.78rem",
letterSpacing: "0.16em",
textTransform: "uppercase",
textDecoration: "none",
padding: "1rem 0",
color: "#4a5060",
borderBottom: i < links.length - 1 ? "1px solid #1c1f26" : "none",
transition: "color 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.color = "#c8a96e")}
onMouseLeave={(e) => (e.currentTarget.style.color = "#4a5060")}
>
<span style={{ color: "#c8a96e", marginRight: "0.75rem", fontSize: "0.6rem" }}>
{String(i + 1).padStart(2, "0")}
</span>
{l.label}
</a>
))}
</div>
</div>
</header>
);
}

322
components/Projects.tsx Normal file
View File

@@ -0,0 +1,322 @@
"use client";
import { useEffect, useRef, useState } from "react";
import SafeImage from "./SafeImage";
interface Task {
name: string;
description: string;
status: string;
technologies: string[];
}
interface ProjectLink {
name: string;
url: string;
}
interface Project {
name: string;
description: string;
logo?: string;
links?: ProjectLink[];
tasks: Task[];
}
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]);
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 }}
>
{/* 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
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
lineHeight: 1.7,
color: "#6b7280",
marginBottom: "1.5rem",
}}
>
{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}
</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
</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>
{/* 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>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", color: "#4a5060", lineHeight: 1.6, margin: 0 }}>
{task.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" }}>
{tech}
</span>
))}
</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();
}, []);
return (
<section id="projects" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
{/* Section header */}
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
Selected Work
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: "1.5rem" }}>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Projects
</h2>
<span
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.7rem",
color: "#4a5060",
letterSpacing: "0.12em",
}}
>
{String(projects.length).padStart(2, "0")} total
</span>
</div>
<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",
}}
>
{projects.map((project, i) => (
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
<ProjectCard project={project} index={i} />
</div>
))}
</div>
</div>
</section>
);
}

62
components/SafeImage.tsx Normal file
View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
interface SafeImageProps {
src: string;
alt: string;
width?: number;
height?: number;
style?: React.CSSProperties;
fallbackLabel?: string; // initials or short label shown when image fails
containerStyle?: React.CSSProperties;
}
export default function SafeImage({
src,
alt,
width,
height,
style,
fallbackLabel,
containerStyle,
}: SafeImageProps) {
const [errored, setErrored] = useState(false);
if (errored) {
if (!fallbackLabel) return null;
return (
<div
style={{
width: width ?? 40,
height: height ?? 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#131720",
border: "1px solid #1c1f26",
fontFamily: "var(--font-bebas), sans-serif",
fontSize: `${Math.min((width ?? 40) * 0.38, 18)}px`,
color: "#c8a96e",
letterSpacing: "0.04em",
flexShrink: 0,
...containerStyle,
}}
>
{fallbackLabel}
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
width={width}
height={height}
style={style}
onError={() => setErrored(true)}
/>
);
}

227
components/Skills.tsx Normal file
View File

@@ -0,0 +1,227 @@
"use client";
import { useEffect, useRef } from "react";
interface SkillsProps {
skills: string[];
languages: { name: string; level: string }[];
interests: string[];
}
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];
const levelColor = (level: string) => {
if (level === "Native") return "#c8a96e";
if (level === "C2") return "#3a7a5a";
if (level === "C1") return "#6b7a50";
return "#4a5060";
};
return (
<section
id="skills"
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Expertise</div>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Skills &amp; Profile
</h2>
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
gap: "2rem",
}}
>
{/* Tech skills */}
<div ref={skillsRef} className="reveal">
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.18em",
color: "#4a5060",
textTransform: "uppercase",
marginBottom: "1.5rem",
paddingBottom: "0.75rem",
borderBottom: "1px solid #1c1f26",
}}
>
Technical Stack
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
{skills.map((skill, i) => (
<span
key={skill}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: `${0.68 * (weights[i % weights.length] ?? 1)}rem`,
padding: "4px 12px",
border: "1px solid #1c1f26",
color: "#6b7280",
background: "#0e1014",
letterSpacing: "0.06em",
transition: "all 0.2s",
cursor: "default",
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#c8a96e";
el.style.color = "#c8a96e";
el.style.background = "rgba(200,169,110,0.05)";
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#1c1f26";
el.style.color = "#6b7280";
el.style.background = "#0e1014";
}}
>
{skill}
</span>
))}
</div>
</div>
{/* Languages + Interests stacked */}
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Languages */}
<div>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.18em",
color: "#4a5060",
textTransform: "uppercase",
marginBottom: "1.5rem",
paddingBottom: "0.75rem",
borderBottom: "1px solid #1c1f26",
}}
>
Languages
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{languages.map((lang) => (
<div
key={lang.name}
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
>
<span
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.95rem",
color: "#e2e4e9",
}}
>
{lang.name}
</span>
<span
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.12em",
padding: "2px 8px",
border: `1px solid ${levelColor(lang.level)}`,
color: levelColor(lang.level),
}}
>
{lang.level}
</span>
</div>
))}
</div>
</div>
{/* Interests */}
<div>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.18em",
color: "#4a5060",
textTransform: "uppercase",
marginBottom: "1.5rem",
paddingBottom: "0.75rem",
borderBottom: "1px solid #1c1f26",
}}
>
Interests
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{interests.map((interest) => (
<span
key={interest}
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.85rem",
padding: "4px 14px",
border: "1px solid #1c1f26",
color: "#6b7280",
background: "#0e1014",
fontStyle: "italic",
transition: "all 0.2s",
cursor: "default",
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#6b5730";
el.style.color = "#c8a96e";
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.borderColor = "#1c1f26";
el.style.color = "#6b7280";
}}
>
{interest}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</section>
);
}