361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } 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 ProjectRow({
|
|
project,
|
|
index,
|
|
isOpen,
|
|
onToggle
|
|
}: {
|
|
project: Project;
|
|
index: number;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const allTechs = Array.from(
|
|
new Set(project.tasks.flatMap((t) => t.technologies))
|
|
);
|
|
const doneCount = project.tasks.filter((t) => t.status === "Done").length;
|
|
|
|
return (
|
|
<div
|
|
className="project-row"
|
|
style={{
|
|
borderBottom: "1px solid #1c1f26",
|
|
}}
|
|
>
|
|
{/* Header bar (always visible) */}
|
|
<button
|
|
onClick={onToggle}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
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";
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "baseline", gap: "2rem" }}>
|
|
<span
|
|
className="font-mono"
|
|
style={{
|
|
fontFamily: "var(--font-jetbrains), monospace",
|
|
fontSize: "0.85rem",
|
|
color: "#4a5060",
|
|
letterSpacing: "0.1em",
|
|
}}
|
|
>
|
|
{String(index + 1).padStart(2, "0")}
|
|
</span>
|
|
|
|
<h3
|
|
className="project-title font-display"
|
|
style={{
|
|
fontFamily: "var(--font-bebas), sans-serif",
|
|
fontSize: "clamp(2rem, 5vw, 4rem)",
|
|
letterSpacing: "0.02em",
|
|
color: isOpen ? "#c8a96e" : "#e2e4e9",
|
|
transition: "color 0.3s ease",
|
|
margin: 0,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{project.name}
|
|
</h3>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: "2rem" }}>
|
|
<div
|
|
className="toggle-icon"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: "40px",
|
|
height: "40px",
|
|
borderRadius: "50%",
|
|
border: "1px solid #1c1f26",
|
|
color: "#4a5060",
|
|
transition: "all 0.3s ease",
|
|
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Expandable Content */}
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateRows: isOpen ? "1fr" : "0fr",
|
|
transition: "grid-template-rows 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
<div style={{ overflow: "hidden" }}>
|
|
<div style={{ paddingBottom: "3rem", paddingTop: "0.5rem" }}>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "3rem" }}>
|
|
|
|
{/* Top part: Description & Links & Techs */}
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "3rem", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
|
|
<div style={{ flex: "1 1 500px" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.5rem" }}>
|
|
{project.logo && (
|
|
<SafeImage
|
|
src={project.logo}
|
|
alt={project.name}
|
|
width={48}
|
|
height={48}
|
|
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
|
|
style={{ width: "36px", height: "36px", objectFit: "contain", filter: "brightness(0.9)" }}
|
|
/>
|
|
)}
|
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
{project.links?.map((link) => (
|
|
<a
|
|
key={link.name}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
fontFamily: "var(--font-jetbrains), monospace",
|
|
fontSize: "0.65rem",
|
|
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>
|
|
|
|
<p
|
|
style={{
|
|
fontFamily: "var(--font-lora), serif",
|
|
fontSize: "1.05rem",
|
|
lineHeight: 1.8,
|
|
color: "#9ca3af",
|
|
maxWidth: "700px",
|
|
}}
|
|
>
|
|
{project.description}
|
|
</p>
|
|
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "2rem" }}>
|
|
{allTechs.map((tech) => (
|
|
<span key={tech} className="tech-tag" style={{ border: "1px solid #2a2e38", background: "#0e1014" }}>
|
|
{tech}
|
|
</span>
|
|
))}
|
|
</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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|
|
|
|
export default function Projects({ projects }: ProjectsProps) {
|
|
const [openIndex, setOpenIndex] = useState<number | null>(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 (
|
|
<section id="projects" style={{ padding: "8rem 0" }}>
|
|
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem" }}>
|
|
{/* Section header */}
|
|
<div style={{ marginBottom: "6rem" }}>
|
|
<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>
|
|
|
|
{/* List */}
|
|
<div style={{ borderTop: "1px solid #1c1f26" }}>
|
|
{projects.map((project, i) => (
|
|
<ProjectRow
|
|
key={project.name}
|
|
project={project}
|
|
index={i}
|
|
isOpen={openIndex === i}
|
|
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|