stable state
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
interface Task {
|
||||
@@ -27,248 +27,280 @@ 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]);
|
||||
|
||||
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
|
||||
ref={ref}
|
||||
className="project-card reveal"
|
||||
style={{ borderRadius: 0, padding: "2rem", flex: 1, display: "flex", flexDirection: "column", position: "relative", zIndex: 1 }}
|
||||
<div
|
||||
className="project-row"
|
||||
style={{
|
||||
borderBottom: "1px solid #1c1f26",
|
||||
}}
|
||||
>
|
||||
{/* 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
|
||||
{/* Header bar (always visible) */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
fontFamily: "var(--font-lora), serif",
|
||||
fontSize: "0.9rem",
|
||||
lineHeight: 1.7,
|
||||
color: "#6b7280",
|
||||
marginBottom: "1.5rem",
|
||||
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";
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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: "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>
|
||||
<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" }}>
|
||||
|
||||
<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 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();
|
||||
}, []);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section id="projects" style={{ padding: "8rem 2rem" }}>
|
||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||
<section id="projects" style={{ padding: "8rem 0" }}>
|
||||
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem" }}>
|
||||
{/* Section header */}
|
||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
||||
<div style={{ marginBottom: "6rem" }}>
|
||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
|
||||
Selected Work
|
||||
</div>
|
||||
@@ -300,20 +332,16 @@ export default function Projects({ projects }: ProjectsProps) {
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{/* List */}
|
||||
<div style={{ borderTop: "1px solid #1c1f26" }}>
|
||||
{projects.map((project, i) => (
|
||||
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
|
||||
<ProjectCard project={project} index={i} />
|
||||
</div>
|
||||
<ProjectRow
|
||||
key={project.name}
|
||||
project={project}
|
||||
index={i}
|
||||
isOpen={openIndex === i}
|
||||
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user