stable state

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

View File

@@ -15,10 +15,15 @@
--color-progress: #6b5730; --color-progress: #6b5730;
} }
* { box-sizing: border-box; } * {
box-sizing: border-box;
}
/* All page sections sit above the fixed GridCanvas (z-index 0) */ /* All page sections sit above the fixed GridCanvas (z-index 0) */
main > section { position: relative; z-index: 5; } main>section {
position: relative;
z-index: 5;
}
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -32,66 +37,117 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
body::before {
content: ""; ::-webkit-scrollbar {
position: fixed; width: 4px;
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 {
::-webkit-scrollbar-track { background: #07080a; } background: #07080a;
::-webkit-scrollbar-thumb { background: #1c1f26; border-radius: 2px; } }
::-webkit-scrollbar-thumb {
background: #1c1f26;
border-radius: 2px;
}
@keyframes fadeUp { @keyframes fadeUp {
from { opacity: 0; transform: translateY(32px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes slideRight { @keyframes slideRight {
from { transform: scaleX(0); } from {
to { transform: scaleX(1); } transform: scaleX(0);
}
to {
transform: scaleX(1);
}
} }
@keyframes pulse-glow { @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); } 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 { @keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; } 0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
} }
@keyframes scroll-bounce { @keyframes scroll-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(6px); } 0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(6px);
}
} }
.animate-fade-up { .animate-fade-up {
animation: fadeUp 0.75s ease both; animation: fadeUp 0.75s ease both;
} }
.delay-1 { animation-delay: 0.15s; } .delay-1 {
.delay-2 { animation-delay: 0.3s; } animation-delay: 0.15s;
.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 { .delay-2 {
opacity: 0; animation-delay: 0.3s;
transform: translateY(24px);
transition: opacity 0.7s ease, transform 0.7s ease;
} }
.reveal.visible {
opacity: 1; .delay-3 {
transform: translateY(0); animation-delay: 0.45s;
} }
.delay-4 {
animation-delay: 0.6s;
}
.delay-5 {
animation-delay: 0.75s;
}
.delay-6 {
animation-delay: 0.9s;
}
/* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */ /* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */
.grid-bg {} .grid-bg {}
@@ -102,21 +158,26 @@ body::before {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.project-card::before { .project-card::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(135deg, rgba(200,169,110,0.05) 0%, transparent 60%); background: linear-gradient(135deg, rgba(200, 169, 110, 0.05) 0%, transparent 60%);
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
pointer-events: none; pointer-events: none;
} }
.project-card:hover { .project-card:hover {
border-color: #6b5730; border-color: #6b5730;
transform: translateY(-3px); transform: translateY(-3px);
box-shadow: 0 16px 48px rgba(0,0,0,0.5); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.project-card:hover::before {
opacity: 1;
} }
.project-card:hover::before { opacity: 1; }
.tech-tag { .tech-tag {
font-family: var(--font-jetbrains), monospace; font-family: var(--font-jetbrains), monospace;
@@ -129,6 +190,7 @@ body::before {
display: inline-block; display: inline-block;
transition: border-color 0.2s, color 0.2s; transition: border-color 0.2s, color 0.2s;
} }
.tech-tag:hover { .tech-tag:hover {
border-color: #6b5730; border-color: #6b5730;
color: #c8a96e; color: #c8a96e;
@@ -143,6 +205,7 @@ body::before {
transition: color 0.2s; transition: color 0.2s;
position: relative; position: relative;
} }
.nav-link::after { .nav-link::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -153,8 +216,14 @@ body::before {
background: #c8a96e; background: #c8a96e;
transition: width 0.25s ease; transition: width 0.25s ease;
} }
.nav-link:hover { color: #c8a96e; }
.nav-link:hover::after { width: 100%; } .nav-link:hover {
color: #c8a96e;
}
.nav-link:hover::after {
width: 100%;
}
.status-done { .status-done {
font-family: var(--font-jetbrains), monospace; font-family: var(--font-jetbrains), monospace;
@@ -165,6 +234,7 @@ body::before {
color: #3a7a5a; color: #3a7a5a;
text-transform: uppercase; text-transform: uppercase;
} }
.status-progress { .status-progress {
font-family: var(--font-jetbrains), monospace; font-family: var(--font-jetbrains), monospace;
font-size: 0.58rem; font-size: 0.58rem;
@@ -209,11 +279,17 @@ body::before {
background: #1c1f26; background: #1c1f26;
border: 1px solid #1c1f26; border: 1px solid #1c1f26;
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
.hero-bento-grid { grid-template-columns: repeat(2, 1fr); } .hero-bento-grid {
grid-template-columns: repeat(2, 1fr);
}
} }
@media (max-width: 599px) { @media (max-width: 599px) {
.hero-bento-grid { grid-template-columns: 1fr; } .hero-bento-grid {
grid-template-columns: 1fr;
}
} }
/* ── Hero header responsive ──────────────────────────────────── */ /* ── Hero header responsive ──────────────────────────────────── */
@@ -225,8 +301,12 @@ body::before {
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
@media (max-width: 599px) { @media (max-width: 599px) {
.hero-header-strip { flex-direction: column; align-items: flex-start; } .hero-header-strip {
flex-direction: column;
align-items: flex-start;
}
} }
/* ── Hero section padding ────────────────────────────────────── */ /* ── Hero section padding ────────────────────────────────────── */
@@ -236,6 +316,7 @@ body::before {
padding-left: 2rem; padding-left: 2rem;
padding-right: 2rem; padding-right: 2rem;
} }
@media (max-width: 599px) { @media (max-width: 599px) {
.hero-section { .hero-section {
padding-top: 76px; padding-top: 76px;
@@ -245,10 +326,31 @@ body::before {
} }
/* ── Headline cursor blink ───────────────────────────────────── */ /* ── Headline cursor blink ───────────────────────────────────── */
@keyframes cursor-blink {
0%, 100% { opacity: 1; } .mobile-menu-btn {
50% { opacity: 0; } display: flex !important;
} }
@media (min-width: 768px) {
.mobile-menu-btn {
display: none !important;
}
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
nav a:hover .nav-underline { transform: scaleX(1) !important; }
.headline-cursor { .headline-cursor {
display: inline-block; display: inline-block;
width: 3px; width: 3px;
@@ -256,4 +358,4 @@ body::before {
margin-left: 4px; margin-left: 4px;
animation: cursor-blink 1.1s step-end infinite; animation: cursor-blink 1.1s step-end infinite;
vertical-align: baseline; vertical-align: baseline;
} }

View File

@@ -37,7 +37,19 @@ export default function RootLayout({
> >
<body className="min-h-full flex flex-col antialiased" suppressHydrationWarning> <body className="min-h-full flex flex-col antialiased" suppressHydrationWarning>
<GridCanvas /> <GridCanvas />
{children} {/* Noise texture — real DOM element so pointer-events:none is reliable on Android touch */}
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
backgroundImage: "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\")",
pointerEvents: "none",
zIndex: 1,
opacity: 0.35,
}}
/>
{children}
</body> </body>
</html> </html>
); );

View File

@@ -8,7 +8,7 @@ import Skills from "@/components/Skills";
import Contact from "@/components/Contact"; import Contact from "@/components/Contact";
export default function Home() { export default function Home() {
const { about, projects, experiences, education } = portfolioData; const { about, projects, experiences, education, certificates } = portfolioData;
return ( return (
<> <>
@@ -29,7 +29,7 @@ export default function Home() {
/> />
<Projects projects={projects} /> <Projects projects={projects} />
<Experience experiences={experiences} /> <Experience experiences={experiences} />
<Education education={education} /> <Education education={education} certificates={certificates} />
<Skills <Skills
skills={about.skills} skills={about.skills}
languages={about.languages} languages={about.languages}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS
? process.env.ALLOWED_DEV_ORIGINS.split(",")
: [],
}; };
export default nextConfig; export default nextConfig;

1
package-lock.json generated
View File

@@ -3247,6 +3247,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",

BIN
public/kereval_logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/ubo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}