Implement achraf's portfolio
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal 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/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
267
app/globals.css
267
app/globals.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
95
app/page.tsx
95
app/page.tsx
@@ -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}
|
||||
/>
|
||||
<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}
|
||||
<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}
|
||||
/>
|
||||
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
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>
|
||||
);
|
||||
}
|
||||
311
data/portfolio.json
Normal file
311
data/portfolio.json
Normal 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
BIN
public/CLIPortfolioLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
public/ensa.png
Normal file
BIN
public/ensa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
9
public/foon-logo.svg
Normal file
9
public/foon-logo.svg
Normal 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
BIN
public/me.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
BIN
public/typesonlogo-light.png
Normal file
BIN
public/typesonlogo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/veolia_logo.png
Normal file
BIN
public/veolia_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Reference in New Issue
Block a user