Leaflet Blog in Deno Fresh
1"use client";
2
3import { useEffect, useRef, useState } from "preact/hooks";
4import { cx } from "../lib/cx.ts";
5import { Title } from "./typography.tsx";
6
7interface Project {
8 id: string;
9 title: string;
10 description: string;
11 technologies: string[];
12 url: string;
13 demo?: string;
14 year: string;
15 status: "active" | "completed" | "maintained" | "archived";
16}
17
18export function ProjectListItem({ project }: { project: Project }) {
19 const [isHovered, setIsHovered] = useState(false);
20 const [isLeaving, setIsLeaving] = useState(false);
21 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
23 // Clean up any timeouts on unmount
24 useEffect(() => {
25 return () => {
26 if (timeoutRef.current) {
27 clearTimeout(timeoutRef.current);
28 }
29 };
30 }, []);
31
32 const handleMouseEnter = () => {
33 if (timeoutRef.current) {
34 clearTimeout(timeoutRef.current);
35 }
36 setIsLeaving(false);
37 setIsHovered(true);
38 };
39
40 const handleMouseLeave = () => {
41 setIsLeaving(true);
42 timeoutRef.current = setTimeout(() => {
43 setIsHovered(false);
44 setIsLeaving(false);
45 }, 300); // Match animation duration
46 };
47
48 const getStatusColor = (status: string) => {
49 switch (status) {
50 case "active":
51 return "text-green-600 dark:text-green-400";
52 case "completed":
53 return "text-blue-600 dark:text-blue-400";
54 case "maintained":
55 return "text-yellow-600 dark:text-yellow-400";
56 case "archived":
57 return "text-slate-500 dark:text-slate-400";
58 default:
59 return "text-slate-600 dark:text-slate-300";
60 }
61 };
62
63 return (
64 <>
65 {isHovered && (
66 <div
67 className={cx(
68 "fixed inset-0 pointer-events-none z-0",
69 isLeaving ? "animate-fade-out" : "animate-fade-in",
70 )}
71 >
72 <div className="h-full w-full pt-[120px] flex items-center overflow-hidden">
73 <div className="whitespace-nowrap animate-marquee font-serif font-medium uppercase leading-[0.8] text-[20vw] opacity-[0.015] -rotate-12 absolute left-0">
74 {Array(8).fill(project.title).join(" · ")}
75 </div>
76 </div>
77 </div>
78 )}
79 <a
80 href={project.demo || project.url}
81 target="_blank"
82 rel="noopener noreferrer"
83 className="w-full group block"
84 onMouseEnter={handleMouseEnter}
85 onMouseLeave={handleMouseLeave}
86 >
87 <article className="w-full flex flex-row border-b items-stretch relative transition-colors duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
88 <div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity duration-300 ease-[cubic-bezier(0.33,0,0.67,1)]" />
89 <div className="flex-1 py-2 px-4 z-10 relative w-full">
90 <div className="flex items-start justify-between gap-4">
91 <Title className="text-lg w-full flex-1" level="h3">
92 {project.title}
93 </Title>
94 <div className="flex items-center gap-2 shrink-0">
95 <span className="text-xs text-slate-500 dark:text-slate-400">
96 {project.year}
97 </span>
98 <span
99 className={cx(
100 "text-xs font-medium capitalize",
101 getStatusColor(project.status),
102 )}
103 >
104 {project.status}
105 </span>
106 </div>
107 </div>
108
109 <div className="flex flex-wrap gap-1 mt-2">
110 {project.technologies.slice(0, 4).map((tech) => (
111 <span
112 key={tech}
113 className="text-xs px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded-sm text-slate-600 dark:text-slate-300"
114 >
115 {tech}
116 </span>
117 ))}
118 {project.technologies.length > 4 && (
119 <span className="text-xs px-2 py-0.5 text-slate-500 dark:text-slate-400">
120 +{project.technologies.length - 4} more
121 </span>
122 )}
123 </div>
124
125 <div className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] grid-rows-[0fr] group-hover:grid-rows-[1fr] opacity-0 group-hover:opacity-100 mt-3">
126 <div className="overflow-hidden">
127 <p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3 mb-3">
128 {project.description}
129 </p>
130 <div className="flex gap-3">
131 {project.demo && (
132 <a
133 href={project.url}
134 target="_blank"
135 rel="noopener noreferrer"
136 className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
137 onClick={(e) => e.stopPropagation()}
138 >
139 Source
140 </a>
141 )}
142 </div>
143 </div>
144 </div>
145 </div>
146 </article>
147 </a>
148 </>
149 );
150}