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

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run:*)",
"Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/CLIPortfolioLogo.png /c/Users/User/Documents/dev/achraf-portfolio/public/)",
"Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/typesonlogo-light.png /c/Users/User/Documents/dev/achraf-portfolio/public/)",
"Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/foon-logo.svg /c/Users/User/Documents/dev/achraf-portfolio/public/)",
"Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/me.png /c/Users/User/Documents/dev/achraf-portfolio/public/)",
"Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/ensa.png /c/Users/User/Documents/dev/achraf-portfolio/public/)"
]
}
}

View File

@@ -1,26 +1,259 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-bg: #07080a;
--color-surface: #0e1014;
--color-border: #1c1f26;
--color-border-dim: #141619;
--color-text: #e2e4e9;
--color-muted: #4a5060;
--color-accent: #c8a96e;
--color-accent-bright: #e8c887;
--color-accent-dim: #6b5730;
--color-tag-bg: #131720;
--color-done: #3a7a5a;
--color-progress: #6b5730;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* { box-sizing: border-box; }
/* All page sections sit above the fixed GridCanvas (z-index 0) */
main > section { position: relative; z-index: 5; }
html {
scroll-behavior: smooth;
background: #07080a;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: #07080a;
color: #e2e4e9;
font-family: var(--font-lora), Georgia, serif;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 1;
opacity: 0.35;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: #07080a; }
::-webkit-scrollbar-thumb { background: #1c1f26; border-radius: 2px; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideRight {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(200, 169, 110, 0); }
50% { box-shadow: 0 0 20px 4px rgba(200, 169, 110, 0.12); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes scroll-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(6px); }
}
.animate-fade-up {
animation: fadeUp 0.75s ease both;
}
.delay-1 { animation-delay: 0.15s; }
.delay-2 { animation-delay: 0.3s; }
.delay-3 { animation-delay: 0.45s; }
.delay-4 { animation-delay: 0.6s; }
.delay-5 { animation-delay: 0.75s; }
.delay-6 { animation-delay: 0.9s; }
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */
.grid-bg {}
.project-card {
background: #0e1014;
border: 1px solid #1c1f26;
transition: border-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
position: relative;
overflow: hidden;
}
.project-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(200,169,110,0.05) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.project-card:hover {
border-color: #6b5730;
transform: translateY(-3px);
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.project-card:hover::before { opacity: 1; }
.tech-tag {
font-family: var(--font-jetbrains), monospace;
font-size: 0.62rem;
letter-spacing: 0.08em;
padding: 2px 8px;
border: 1px solid #1c1f26;
color: #4a5060;
background: #131720;
display: inline-block;
transition: border-color 0.2s, color 0.2s;
}
.tech-tag:hover {
border-color: #6b5730;
color: #c8a96e;
}
.nav-link {
font-family: var(--font-jetbrains), monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #4a5060;
transition: color 0.2s;
position: relative;
}
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #c8a96e;
transition: width 0.25s ease;
}
.nav-link:hover { color: #c8a96e; }
.nav-link:hover::after { width: 100%; }
.status-done {
font-family: var(--font-jetbrains), monospace;
font-size: 0.58rem;
letter-spacing: 0.1em;
padding: 1px 6px;
border: 1px solid #3a7a5a;
color: #3a7a5a;
text-transform: uppercase;
}
.status-progress {
font-family: var(--font-jetbrains), monospace;
font-size: 0.58rem;
letter-spacing: 0.1em;
padding: 1px 6px;
border: 1px solid #6b5730;
color: #c8954a;
text-transform: uppercase;
}
.section-label {
font-family: var(--font-jetbrains), monospace;
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #c8a96e;
}
.cursor::after {
content: '_';
animation: blink 1s step-end infinite;
color: #c8a96e;
}
.scroll-indicator {
animation: scroll-bounce 1.8s ease-in-out infinite;
}
.accent-bar {
display: block;
width: 32px;
height: 2px;
background: #c8a96e;
transform-origin: left;
}
/* ── Responsive bento grid ───────────────────────────────────── */
.hero-bento-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #1c1f26;
border: 1px solid #1c1f26;
}
@media (max-width: 1023px) {
.hero-bento-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 599px) {
.hero-bento-grid { grid-template-columns: 1fr; }
}
/* ── Hero header responsive ──────────────────────────────────── */
.hero-header-strip {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 2.5rem;
}
@media (max-width: 599px) {
.hero-header-strip { flex-direction: column; align-items: flex-start; }
}
/* ── Hero section padding ────────────────────────────────────── */
.hero-section {
padding-top: 88px;
padding-bottom: 4rem;
padding-left: 2rem;
padding-right: 2rem;
}
@media (max-width: 599px) {
.hero-section {
padding-top: 76px;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
}
/* ── Headline cursor blink ───────────────────────────────────── */
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.headline-cursor {
display: inline-block;
width: 3px;
background: #c8a96e;
margin-left: 4px;
animation: cursor-blink 1.1s step-end infinite;
vertical-align: baseline;
}

View File

@@ -1,33 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Bebas_Neue, JetBrains_Mono, Lora } from "next/font/google";
import "./globals.css";
import GridCanvas from "@/components/GridCanvas";
const geistSans = Geist({
variable: "--font-geist-sans",
const bebas = Bebas_Neue({
weight: "400",
variable: "--font-bebas",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const jetbrains = JetBrains_Mono({
variable: "--font-jetbrains",
subsets: ["latin"],
weight: ["400", "500"],
});
const lora = Lora({
variable: "--font-lora",
subsets: ["latin"],
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Achraf Achkari — Technical Lead & Software Engineer",
description:
"Personal portfolio and project hub. Technical Lead at Kereval, building software with Java, TypeScript, and modern frameworks.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${bebas.variable} ${jetbrains.variable} ${lora.variable} h-full`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col antialiased" suppressHydrationWarning>
<GridCanvas />
{children}
</body>
</html>
);
}

View File

@@ -1,65 +1,46 @@
import Image from "next/image";
import portfolioData from "@/data/portfolio.json";
import Navigation from "@/components/Navigation";
import Hero from "@/components/Hero";
import Projects from "@/components/Projects";
import Experience from "@/components/Experience";
import Education from "@/components/Education";
import Skills from "@/components/Skills";
import Contact from "@/components/Contact";
export default function Home() {
const { about, projects, experiences, education } = portfolioData;
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<>
<Navigation />
<main>
<Hero
name={about.FullName.split(" ")[0]}
lastName={about.FullName.split(" ").slice(1).join(" ")}
title={about.title}
description={about.description}
skills={about.skills}
projects={projects}
experiences={experiences}
education={education}
languages={about.languages}
interests={about.interests}
location={about.contact.Address}
/>
<Projects projects={projects} />
<Experience experiences={experiences} />
<Education education={education} />
<Skills
skills={about.skills}
languages={about.languages}
interests={about.interests}
/>
<Contact
contact={about.contact}
fullName={about.FullName}
image={about.image}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
</>
);
}

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>
);
}

311
data/portfolio.json Normal file
View File

@@ -0,0 +1,311 @@
{
"name":"Achraf",
"lastName":"Achkari",
"projects":[
{
"name":"Gazelle",
"description":"Gazelle Test Bed",
"logo": "https://gazelle.ihe.net/files/LOGO_0.png",
"links":[
{
"name":"Deployed",
"url":"https://gazelle.ihe.net/"
},
{
"name":"Github",
"url":"https://gitlab.inria.fr/gazelle/"
}
],
"tasks":[
{
"name":"Gazelle Proxy",
"description":"Coordinate the renovation the Core Gazelle Proxy to support new protocols and security standards",
"status":"Done",
"technologies":[
"Java 17/21",
"Jakarta EE",
"Quarkus",
"Netty",
"Next.js",
"Docker"
]
},
{
"name":"Gazelle Test Management",
"description":"Develop a new test management module to orchestrate and track testing campaigns across systems",
"status":"In Progress",
"technologies":[
"Java 17",
"Jakarta EE",
"Quarkus",
"PostgreSQL"
]
},
{
"name":"Validation Service API",
"description":"Create a new API to unify all the validation services consumed by Gazelle",
"status":"Done",
"technologies":[
"Java 17",
"MicroProfile",
"JAX-RS",
"TDD"
]
},
{
"name":"Gazelle Objects Checker",
"description":"Renovate the validator generator engine to recent JDK and enhance the validation rules",
"status":"Done",
"technologies":[
"Java",
"OCL",
"XML",
"SOAP",
"BASH"
]
}
]
},
{
"name":"Typeson",
"description":"Polymorphic JSON serialization and deserialization library",
"logo": "/typesonlogo-light.png",
"links":[
{
"name":"Github",
"url":"https://github.com/achachraf/Typeson"
},
{
"name":"Maven",
"url":"https://mvnrepository.com/artifact/io.github.achachraf/typeson/1.0.1"
}
],
"tasks":[
{
"name":"JSON Object Mapper",
"description":"Develop a new JSON object mapper to support polymorphic serialization and deserialization",
"status":"Done",
"technologies":[
"Java 17",
"GraphScan",
"Reflection",
"TDD"
]
},
{
"name":"Integration with Jackson",
"description":"Integrate Typeson with Jackson to support polymorphic serialization and deserialization",
"status":"Done",
"technologies":[
"Java 17",
"Jackson"
]
}
]
},
{
"name":"FOON",
"description":"TypeScript SDK for semantic JSON transformation using LLMs — turn messy JSON into your schema, safely",
"logo": "/foon-logo.svg",
"links":[
{
"name":"Website",
"url":"https://foon.ink"
},
{
"name":"Github",
"url":"https://github.com/achachraf/foon-sdk"
},
{
"name":"NPM",
"url":"https://www.npmjs.com/package/foon-sdk"
}
],
"tasks":[
{
"name":"Mapping Engine",
"description":"Core engine that uses LLM providers to generate semantic mapping plans, then deterministically executes them against a JSON Schema with confidence scoring",
"status":"Done",
"technologies":[
"TypeScript",
"AJV",
"JSONPath",
"LRU Cache"
]
},
{
"name":"Security Layer",
"description":"Input validation, prompt injection protection, sensitive field redaction, and prototype pollution detection",
"status":"Done",
"technologies":[
"TypeScript",
"Schema Validation"
]
},
{
"name":"Express Middleware",
"description":"Drop-in Express integration with dual-route transformation and configurable error handling",
"status":"Done",
"technologies":[
"TypeScript",
"Express",
"REST"
]
},
{
"name":"Marketing Site & Docs",
"description":"Interactive demo website with live transformation showcase, MDX documentation, and waitlist backend",
"status":"Done",
"technologies":[
"Next.js",
"React 19",
"Tailwind CSS",
"MDX",
"Upstash Redis"
]
}
]
},
{
"name":"CLI Portfolio",
"description":"Create a CLI portfolio to display my resume",
"logo": "/CLIPortfolioLogo.png",
"tasks":[
{
"name":"Main CLI",
"description":"Create the main CLI with basic commands handling",
"status":"Done",
"technologies":[
"Next.js",
"SSR",
"Typescript"
]
},
{
"name":"Enhanced terminal",
"description":"Enhance the terminal to support graphical content",
"status":"Done",
"technologies":[
"Next.js",
"Next API",
"SSR",
"Typescript",
"Jest"
]
}
]
}
],
"experiences": [
{
"name":"techlead-kereval",
"company":"Kereval",
"position":"TechLead",
"description":"Coordinate the technical team to deliver the best quality software",
"startDate":"01-01-2023",
"endDate":"current",
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
},
{
"name":"software-engineer-kereval",
"company":"Kereval",
"position":"Software Engineer",
"description":"Technical Referent for the Gazelle Test Bed tools",
"startDate":"01-10-2021",
"endDate":"31-12-2022",
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
},
{
"name":"intern-kereval",
"company":"Kereval",
"position":"Intern",
"description":"Developed and improved the Gazelle Test Bed tools",
"startDate":"06-04-2021",
"endDate":"31-09-2021",
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
},
{
"name":"intern-veolia",
"company":"Veolia",
"position":"Intern",
"description":"Developed Ticketing System for the company's employees",
"startDate":"01-07-2019",
"endDate":"01-08-2019",
"logo": "/veolia_logo.png"
}
],
"education":[
{
"degree":"Master Degree in Computer Science",
"school":"Université de Bretagne Occidentale",
"startDate":"2016",
"endDate":"2020",
"logo": "/ensa.png"
},
{
"degree":"Engineer Degree in Computer Science",
"school":"Ecole Nationale des Sciences Appliquées de Tanger",
"startDate":"2016",
"endDate":"2020",
"logo": "/ensa.png"
},
{
"degree":"Baccalauréat",
"school":"Lycée Ibn Batouta",
"startDate":"2015",
"endDate":"2016"
}
],
"about":{
"title":"Software Engineer",
"FullName":"Achraf Achkari",
"image": "/me.png",
"description":"I am a Technial Lead at Kereval. I have a strong experience in software development and I am passionate about new technologies. I am always looking for new challenges and I am eager to learn new things.",
"skills":[
"Java",
"Jakarta EE",
"MicroProfile",
"Quarkus",
"Netty",
"Next.js",
"Docker",
"TDD",
"OCL",
"XML",
"SOAP",
"BASH",
"GraphScan",
"Reflection",
"Typescript",
"Jest"
],
"contact":{
"email":"achrafachkari@gmail.com",
"phone":"+33 7 82 92 98 79",
"linkedin":"https://www.linkedin.com/in/achraf-achkari/",
"github":"https://github.com/achachraf/",
"Address":"Toulouse, France"
},
"languages":[
{
"name":"French",
"level":"C1"
},
{
"name":"English",
"level":"C2"
},
{
"name":"Arabic",
"level":"Native"
}
],
"interests":[
"Mathematics",
"Piano",
"Writing",
"Football",
"Puzzles"
]
}
}

BIN
public/CLIPortfolioLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
public/ensa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

9
public/foon-logo.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" rx="108" fill="#0a0a0a"/>
<!-- Left brace { -->
<path d="M168 136 C136 136, 124 160, 124 184 L124 232 C124 252, 108 264, 88 268 C108 272, 124 284, 124 304 L124 352 C124 376, 136 400, 168 400" stroke="#00ff88" stroke-width="32" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Right brace } -->
<path d="M344 136 C376 136, 388 160, 388 184 L388 232 C388 252, 404 264, 424 268 C404 272, 388 284, 388 304 L388 352 C388 376, 376 400, 344 400" stroke="#00ff88" stroke-width="32" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Transform arrow > in center -->
<path d="M216 216 L280 268 L216 320" stroke="#ededed" stroke-width="36" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 849 B

BIN
public/me.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/veolia_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB