feat: Project Carousel
This commit is contained in:
@@ -1,9 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, ExternalLink, GitFork, Layout, Star } from 'lucide-react';
|
||||
import { PROJECTS } from '../data/contentData';
|
||||
|
||||
export function ProjectsSection() {
|
||||
const [repoStats, setRepoStats] = useState<Record<string, { stars: number; forks: number }>>({});
|
||||
const [orderedProjects, setOrderedProjects] = useState(PROJECTS);
|
||||
const projectRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
const projectPositions = useRef(new Map<string, DOMRect>());
|
||||
|
||||
const handleProjectPromote = (projectId: string) => {
|
||||
setOrderedProjects((prev) => {
|
||||
const target = prev.find((project) => project.id === projectId);
|
||||
if (!target) return prev;
|
||||
const remaining = prev.filter((project) => project.id !== projectId);
|
||||
return [target, ...remaining];
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nextPositions = new Map<string, DOMRect>();
|
||||
|
||||
projectRefs.current.forEach((node, id) => {
|
||||
nextPositions.set(id, node.getBoundingClientRect());
|
||||
});
|
||||
|
||||
projectRefs.current.forEach((node, id) => {
|
||||
const prev = projectPositions.current.get(id);
|
||||
const next = nextPositions.get(id);
|
||||
if (!prev || !next) return;
|
||||
|
||||
const deltaX = prev.left - next.left;
|
||||
const deltaY = prev.top - next.top;
|
||||
|
||||
if (deltaX || deltaY) {
|
||||
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
node.style.transition = 'transform 0s';
|
||||
requestAnimationFrame(() => {
|
||||
node.style.transform = '';
|
||||
node.style.transition = 'transform 320ms ease';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
projectPositions.current = nextPositions;
|
||||
}, [orderedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -61,77 +101,117 @@ export function ProjectsSection() {
|
||||
<Layout className="w-5 h-5 accent-text" /> recent_projects/
|
||||
</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
{PROJECTS.map((project, idx) => (
|
||||
<article
|
||||
{orderedProjects.slice(0, 3).map((project, idx) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`group relative p-6 bg-(--bg-surface) sketch-border-subtle cursor-pointer overflow-visible ${idx % 2 === 0 ? 'transform rotate-[0.5deg]' : 'transform -rotate-[0.5deg]'}`}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
projectRefs.current.set(project.id, node);
|
||||
} else {
|
||||
projectRefs.current.delete(project.id);
|
||||
}
|
||||
}}
|
||||
className="transition-transform duration-300 ease-out"
|
||||
>
|
||||
{project.ribbon && (
|
||||
<div className="absolute -top-2 -right-10 rotate-12 z-10">
|
||||
<div className="sketch-border-subtle bg-(--color-accent) text-(--bg-base) px-6 py-1 text-xs font-sketch uppercase tracking-wide shadow-lg">
|
||||
{project.ribbon}
|
||||
<article
|
||||
className={`group relative p-6 bg-(--bg-surface) sketch-border-subtle cursor-pointer overflow-visible ${idx % 2 === 0 ? 'transform rotate-[0.5deg]' : 'transform -rotate-[0.5deg]'}`}
|
||||
>
|
||||
{project.ribbon && (
|
||||
<div className="absolute -top-2 -right-10 rotate-12 z-10">
|
||||
<div className="sketch-border-subtle bg-(--color-accent) text-(--bg-base) px-6 py-1 text-xs font-sketch uppercase tracking-wide shadow-lg">
|
||||
{project.ribbon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-(--border-main) group-hover:bg-(--color-accent) transition-colors duration-300 opacity-50" />
|
||||
)}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-(--border-main) group-hover:bg-(--color-accent) transition-colors duration-300 opacity-50" />
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="group/icon relative flex items-center gap-2 py-2.5 ps-2.5 pe-1.5 pr-6 sketch-border-subtle bg-(--bg-panel) text-(--text-muted) hover:text-(--color-accent) hover:scale-110 transition-all transform -rotate-3">
|
||||
<span className="flex items-center justify-center">
|
||||
{project.icon}
|
||||
</span>
|
||||
<ChevronRight className="absolute right-1.5 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-accent) opacity-90 animate-pulse transition-all drop-shadow-[0_0_16px_var(--color-accent)] group-hover/icon:opacity-0 group-hover/icon:scale-0 group-focus-within/icon:opacity-0 group-focus-within/icon:scale-0" />
|
||||
{project.github && (
|
||||
<div className="flex items-center gap-3 text-xs text-(--text-muted) overflow-hidden max-w-0 opacity-0 group-hover/icon:max-w-35 group-hover/icon:opacity-100 transition-all duration-300">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 text-(--color-accent)" />
|
||||
{repoStats[project.id]?.stars ?? project.stars ?? '—'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-3 h-3 text-(--color-accent)" />
|
||||
{repoStats[project.id]?.forks ?? project.forks ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-(--text-main) font-bold text-lg font-mono">
|
||||
{project.link ? (
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 hover:text-(--color-accent) transition-colors"
|
||||
>
|
||||
{project.title}
|
||||
<ExternalLink className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity accent-text" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{project.title}
|
||||
<ExternalLink className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity accent-text" />
|
||||
</span>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="group/icon relative flex items-center gap-2 py-2.5 ps-2.5 pe-1.5 pr-6 sketch-border-subtle bg-(--bg-panel) text-(--text-muted) hover:text-(--color-accent) hover:scale-110 transition-all transform -rotate-3">
|
||||
<span className="flex items-center justify-center">
|
||||
{project.icon}
|
||||
</span>
|
||||
<ChevronRight className="absolute right-1.5 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-accent) opacity-90 animate-pulse transition-all drop-shadow-[0_0_16px_var(--color-accent)] group-hover/icon:opacity-0 group-hover/icon:scale-0 group-focus-within/icon:opacity-0 group-focus-within/icon:scale-0" />
|
||||
{project.github && (
|
||||
<div className="flex items-center gap-3 text-xs text-(--text-muted) overflow-hidden max-w-0 opacity-0 group-hover/icon:max-w-35 group-hover/icon:opacity-100 transition-all duration-300">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 text-(--color-accent)" />
|
||||
{repoStats[project.id]?.stars ?? project.stars ?? '—'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-3 h-3 text-(--color-accent)" />
|
||||
{repoStats[project.id]?.forks ?? project.forks ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</h3>
|
||||
<span className="font-sketch text-sm text-(--text-muted) accent-text">{project.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-(--text-main) font-bold text-lg font-mono">
|
||||
{project.link ? (
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 hover:text-(--color-accent) transition-colors"
|
||||
>
|
||||
{project.title}
|
||||
<ExternalLink className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity accent-text" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{project.title}
|
||||
<ExternalLink className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity accent-text" />
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<span className="font-sketch text-sm text-(--text-muted) accent-text">{project.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-(--text-muted) mb-5 leading-relaxed font-mono">
|
||||
{project.desc}
|
||||
</p>
|
||||
<p className="text-sm text-(--text-muted) mb-5 leading-relaxed font-mono">
|
||||
{project.desc}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tech.map((t) => (
|
||||
<span key={t} className="text-xs px-2 py-1 bg-(--bg-panel) sketch-border-subtle text-(--text-muted) font-mono">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tech.map((t) => (
|
||||
<span key={t} className="text-xs px-2 py-1 bg-(--bg-panel) sketch-border-subtle text-(--text-muted) font-mono">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{orderedProjects.length > 3 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{orderedProjects.slice(3).map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
projectRefs.current.set(project.id, node);
|
||||
} else {
|
||||
projectRefs.current.delete(project.id);
|
||||
}
|
||||
}}
|
||||
className="transition-transform duration-300 ease-out"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProjectPromote(project.id)}
|
||||
className="group/icon relative flex items-center gap-2 py-2.5 ps-2.5 pe-1.5 pr-6 sketch-border-subtle bg-(--bg-panel) text-(--text-muted) hover:text-(--color-accent) hover:scale-105 transition-all"
|
||||
aria-label={`Promote ${project.title}`}
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
{project.icon}
|
||||
</span>
|
||||
<ChevronRight className="absolute right-1.5 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-accent) opacity-90 animate-pulse transition-all drop-shadow-[0_0_16px_var(--color-accent)] group-hover/icon:opacity-0 group-hover/icon:scale-0 group-focus-within/icon:opacity-0 group-focus-within/icon:scale-0" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user