Implement achraf's portfolio
This commit is contained in:
300
components/Contact.tsx
Normal file
300
components/Contact.tsx
Normal 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'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'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
170
components/Education.tsx
Normal 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
195
components/Experience.tsx
Normal 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
181
components/GridCanvas.tsx
Normal 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
413
components/Hero.tsx
Normal 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'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
219
components/Navigation.tsx
Normal 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
322
components/Projects.tsx
Normal 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
62
components/SafeImage.tsx
Normal 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
227
components/Skills.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user