Implement achraf's portfolio

This commit is contained in:
achraf
2026-03-31 00:28:00 +02:00
parent fcd8d3d986
commit a4ee12732d
20 changed files with 2734 additions and 87 deletions

322
components/Projects.tsx Normal file
View File

@@ -0,0 +1,322 @@
"use client";
import { useEffect, useRef, useState } from "react";
import SafeImage from "./SafeImage";
interface Task {
name: string;
description: string;
status: string;
technologies: string[];
}
interface ProjectLink {
name: string;
url: string;
}
interface Project {
name: string;
description: string;
logo?: string;
links?: ProjectLink[];
tasks: Task[];
}
interface ProjectsProps {
projects: Project[];
}
function ProjectCard({ project, index }: { project: Project; index: number }) {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => el.classList.add("visible"), index * 80);
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, [index]);
const allTechs = Array.from(
new Set(project.tasks.flatMap((t) => t.technologies))
);
const doneCount = project.tasks.filter((t) => t.status === "Done").length;
return (
<div
ref={ref}
className="project-card reveal"
style={{ borderRadius: 0, padding: "2rem", flex: 1, display: "flex", flexDirection: "column", position: "relative", zIndex: 1 }}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.25rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
{project.logo && (
<SafeImage
src={project.logo}
alt={project.name}
width={40}
height={40}
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
style={{ width: "28px", height: "28px", objectFit: "contain", filter: "brightness(0.85)" }}
containerStyle={{ background: "#0a0b0e" }}
/>
)}
<div>
<div
className="section-label"
style={{ fontSize: "0.58rem", marginBottom: "0.25rem" }}
>
{String(index + 1).padStart(2, "0")}
</div>
<h3
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "1.6rem",
letterSpacing: "0.04em",
color: "#e2e4e9",
}}
>
{project.name}
</h3>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", justifyContent: "flex-end" }}>
{project.links?.map((link) => (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.6rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
padding: "3px 10px",
border: "1px solid #1c1f26",
color: "#4a5060",
textDecoration: "none",
transition: "border-color 0.2s, color 0.2s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
(e.currentTarget as HTMLElement).style.color = "#c8a96e";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#1c1f26";
(e.currentTarget as HTMLElement).style.color = "#4a5060";
}}
>
{link.name}
</a>
))}
</div>
</div>
{/* Description */}
<p
style={{
fontFamily: "var(--font-lora), serif",
fontSize: "0.9rem",
lineHeight: 1.7,
color: "#6b7280",
marginBottom: "1.5rem",
}}
>
{project.description}
</p>
{/* Tech tags */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
{allTechs.slice(0, 8).map((tech) => (
<span key={tech} className="tech-tag">{tech}</span>
))}
{allTechs.length > 8 && (
<span className="tech-tag" style={{ color: "#c8a96e", borderColor: "#6b5730" }}>
+{allTechs.length - 8}
</span>
)}
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "auto", paddingTop: "1.5rem" }}>
<div
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
color: "#3a7a5a",
letterSpacing: "0.08em",
}}
>
{doneCount}/{project.tasks.length} tasks complete
</div>
<button
onClick={() => setExpanded(!expanded)}
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.62rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
background: "none",
border: "none",
cursor: "pointer",
color: "#c8a96e",
padding: 0,
transition: "opacity 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.7")}
onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
>
{expanded ? "— Hide tasks" : "+ Show tasks"}
</button>
</div>
{/* Tasks */}
{expanded && (
<div style={{ marginTop: "1.5rem", borderTop: "1px solid #1c1f26", paddingTop: "1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{project.tasks.map((task) => (
<div
key={task.name}
style={{
display: "flex",
gap: "1rem",
alignItems: "flex-start",
}}
>
<div
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
marginTop: "6px",
flexShrink: 0,
}}
/>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
<span
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.78rem",
color: "#e2e4e9",
letterSpacing: "0.04em",
}}
>
{task.name}
</span>
<span className={task.status === "Done" ? "status-done" : "status-progress"}>
{task.status}
</span>
</div>
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", color: "#4a5060", lineHeight: 1.6, margin: 0 }}>
{task.description}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem", marginTop: "0.5rem" }}>
{task.technologies.map((tech) => (
<span key={tech} className="tech-tag" style={{ fontSize: "0.58rem" }}>
{tech}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default function Projects({ projects }: ProjectsProps) {
const headingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = headingRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add("visible");
obs.disconnect();
}
},
{ threshold: 0.1 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<section id="projects" style={{ padding: "8rem 2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
{/* Section header */}
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
Selected Work
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: "1.5rem" }}>
<h2
className="font-display"
style={{
fontFamily: "var(--font-bebas), sans-serif",
fontSize: "clamp(2.5rem, 6vw, 5rem)",
letterSpacing: "0.04em",
color: "#e2e4e9",
lineHeight: 1,
}}
>
Projects
</h2>
<span
className="font-mono"
style={{
fontFamily: "var(--font-jetbrains), monospace",
fontSize: "0.7rem",
color: "#4a5060",
letterSpacing: "0.12em",
}}
>
{String(projects.length).padStart(2, "0")} total
</span>
</div>
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
</div>
{/* Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 520px), 1fr))",
gap: "1.5px",
background: "#1c1f26",
border: "1px solid #1c1f26",
}}
>
{projects.map((project, i) => (
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
<ProjectCard project={project} index={i} />
</div>
))}
</div>
</div>
</section>
);
}