diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c550055 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e23fce5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/layout.tsx b/app/layout.tsx index 8085e2c..648cd20 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,6 +25,9 @@ export const metadata: Metadata = { 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.", + icons: { + icon: "/favicon.svg", + }, }; export default function RootLayout({ diff --git a/components/Hero.tsx b/components/Hero.tsx index 4995f17..8e69371 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRef, useState } from "react"; import SafeImage from "./SafeImage"; interface Task { @@ -60,6 +61,9 @@ function BentoCard({ children, accent = false, delay = 0, + className = "", + style = {}, + onClick, }: { num: string; label: string; @@ -67,10 +71,33 @@ function BentoCard({ children: React.ReactNode; accent?: boolean; delay?: number; + className?: string; + style?: React.CSSProperties; + onClick?: (e: React.MouseEvent) => void; }) { + const cardRef = useRef(null); + const [mousePosition, setMousePosition] = useState({ x: -1000, y: -1000 }); + const [isHovered, setIsHovered] = useState(false); + + const handleMouseMove = (e: React.MouseEvent) => { + if (cardRef.current) { + const rect = cardRef.current.getBoundingClientRect(); + setMousePosition({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }; + return ( setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} style={{ animationName: "fadeUp", animationDuration: "0.7s", @@ -80,59 +107,46 @@ function BentoCard({ display: "flex", flexDirection: "column", padding: "1.75rem", - border: "1px solid #1c1f26", - background: accent ? "rgba(200,169,110,0.03)" : "#0e1014", + border: "1px solid rgba(255,255,255,0.05)", + background: accent ? "rgba(200,169,110,0.03)" : "rgba(14,16,20,0.4)", + backdropFilter: "blur(12px)", 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"; + transition: "border-color 0.4s ease, transform 0.4s ease, background 0.4s ease, box-shadow 0.4s ease", + borderRadius: "16px", + minHeight: "220px", + boxShadow: isHovered ? "0 8px 32px rgba(0,0,0,0.4)" : "0 4px 16px rgba(0,0,0,0.2)", + transform: isHovered ? "translateY(-4px) scale(1.01)" : "translateY(0) scale(1)", + borderColor: isHovered ? "rgba(200,169,110,0.3)" : "rgba(255,255,255,0.05)", + ...style, }} > + {/* Spotlight Effect */}
-
-
+
+
{num} · {label}
-
+
-
{children}
+
{children}
); } @@ -152,11 +166,17 @@ export default function Hero({ interests, location, }: HeroProps) { - - const feat1 = projects[2]; // FOON - const feat2 = projects[0]; // Gazelle + const feat1 = projects.find(p => p.name === "FOON") || projects[0]; + const feat2 = projects.find(p => p.name === "Gazelle") || projects[1]; const topSkills = skills.slice(0, 10); + const handleFeatureClick = (projectName: string) => { + const idx = projects.findIndex((p) => p.name === projectName); + if (idx !== -1) { + window.dispatchEvent(new CustomEvent("openProject", { detail: { index: idx } })); + } + }; + const levelColor = (level: string) => { if (level === "Native") return "#c8a96e"; if (level === "C2") return "#3a7a5a"; @@ -167,9 +187,47 @@ export default function Hero({
-
+ + +
{/* ── Headline ─────────────────────────────────────────────────── */}
Let's bend
- spacetime + spacetime
@@ -208,7 +266,7 @@ export default function Hero({
-
-

+

+

{description}

-
- - - - - - {location} +
+
+ + Based in {location}
@@ -238,36 +293,68 @@ export default function Hero({ {/* ── Divider ───────────────────────────────────────────────── */}
- {/* ── 3 × 2 Bento grid ──────────────────────────────────────── */} -
+ {/* ── Creative Bento grid ──────────────────────────────────────── */} +
- {/* 01 — FOON */} - -
+ {/* 01 — FOON (Span 2x2: Large Feature) */} + handleFeatureClick(feat1.name)} + > +
{feat1.logo && ( - +
+ +
)} -

- {feat1.name} -

+
+

+ {feat1.name} +

+ + Featured Project + +
-

+ +

{feat1.description}

-
- {feat1.tasks.flatMap((t) => t.technologies).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).map((tech) => ( - {tech} + +
+ {feat1.tasks.slice(0, 2).map((task) => ( +
+
+ {task.name} + {task.status} +
+
+ {task.description.length > 80 ? task.description.slice(0, 80) + "..." : task.description} +
+
))}
+ +
+ {feat1.tasks.flatMap((t) => t.technologies).filter((v, i, a) => a.indexOf(v) === i).slice(0, 6).map((tech) => ( + {tech} + ))} +
+ {feat1.links && ( -
- {feat1.links.slice(0, 2).map((l) => ( - +
+ {feat1.links.map((l) => ( + ↗ {l.name} ))} @@ -275,50 +362,56 @@ export default function Hero({ )} - {/* 02 — Gazelle */} - + {/* 02 — Gazelle (Span 2x1) */} + handleFeatureClick(feat2.name)} + >
{feat2.logo && ( )} -

+

{feat2.name}

-

+

{feat2.description}

-
- {feat2.tasks.map((task) => ( -
- - +
+ {feat2.tasks.slice(0, 4).map((task) => ( +
+ + {task.name} - {task.status}
))}
- {/* 03 — Experience */} - + {/* 03 — Experience (Span 1x1) */} +

Experience

-
- {experiences.map((exp, i) => ( -
-
+
+ {experiences.slice(0, 2).map((exp, i) => ( +
+
-
+
{exp.position}
-
- {exp.company} · {exp.endDate === "current" ? "Present" : exp.endDate.split("-")[2]} +
+ {exp.company}
@@ -326,71 +419,78 @@ export default function Hero({
- {/* 04 — Education */} - + {/* 04 — Education (Span 1x1) */} +

Education

-
- {education.map((edu, i) => ( -
-
+
+ {education.slice(0, 2).map((edu, i) => ( +
+
{edu.degree}
-
- {edu.school.split(" ").slice(-2).join(" ")} · {edu.endDate} +
+ {edu.school.split(" ").slice(-2).join(" ")}
))}
- {/* 05 — Skills */} - -

- {skills.length} Skills -

-
+ {/* 05 — Skills (Span 2x1) */} + +
+

+ Arsenal +

+ + {skills.length} TECHNOLOGIES + +
+
{topSkills.map((skill, i) => ( - + {skill} ))} {skills.length > 10 && ( - - +{skills.length - 10} + + +{skills.length - 10} more )}
- {/* 06 — About */} - -

- About + {/* 06 — About (Span 2x1) */} + +

+ Beyond Code

-
-
- Languages +
+
+
+ Languages +
+
+ {languages.map((lang) => ( + + {lang.name} {lang.level} + + ))} +
-
- {languages.map((lang) => ( - - {lang.name} {lang.level} - - ))} -
-
-
-
- Interests -
-
- {interests.map((interest, i) => ( - - {interest}{i < interests.length - 1 && ·} - - ))} +
+
+ Interests +
+
+ {interests.map((interest) => ( + + {interest} + + ))} +
@@ -399,13 +499,13 @@ export default function Hero({ {/* ── Scroll hint ───────────────────────────────────────────── */}
-
- +
+ Scroll to explore -
+

diff --git a/components/Projects.tsx b/components/Projects.tsx index 71d6ff2..2c4f00e 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import SafeImage from "./SafeImage"; interface Task { @@ -296,6 +296,16 @@ function ProjectRow({ export default function Projects({ projects }: ProjectsProps) { const [openIndex, setOpenIndex] = useState(0); + useEffect(() => { + const handleOpenProject = (e: CustomEvent) => { + if (typeof e.detail?.index === "number") { + setOpenIndex(e.detail.index); + } + }; + window.addEventListener("openProject", handleOpenProject as EventListener); + return () => window.removeEventListener("openProject", handleOpenProject as EventListener); + }, []); + return (
diff --git a/data/portfolio.json b/data/portfolio.json index 5f18a21..b7fb6d2 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -2,6 +2,69 @@ "name": "Achraf", "lastName": "Achkari", "projects": [ + { + "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": "Gazelle", "description": "Gazelle Test Bed", @@ -103,69 +166,6 @@ } ] }, - { - "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", @@ -256,28 +256,37 @@ "endDate": "2016" } ], + "certificates": [], "about": { "title": "Techlead 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", + "Java 17/21", + "TypeScript", + "Next.js / React", "Quarkus", - "Netty", - "Next.js", + "Jakarta EE", "Docker", - "TDD", + "PostgreSQL", + "Technical Leadership", + "Microservices", + "TDD & Clean Code", + "Node.js (Express)", + "REST APIs (JAX-RS)", + "Tailwind CSS", + "Redis", + "Agile / Scrum", + "Software Architecture", + "Netty", + "MicroProfile", + "Jest", + "Linux & Bash", + "JSON Schema", + "SOAP / XML", "OCL", - "XML", - "SOAP", - "BASH", - "GraphScan", - "Reflection", - "Typescript", - "Jest" + "Engine Dev / Reflection" ], "contact": { "email": "achrafachkari@gmail.com", diff --git a/next.config.ts b/next.config.ts index d2ca7dd..905c37c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: 'standalone', allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS ? process.env.ALLOWED_DEV_ORIGINS.split(",") : [], diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..07b5fb4 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + AA +