A project tracker for decentralized social media platforms, clients, and tools
1import { useState } from 'react';
2import type { Project } from '../types/project';
3
4interface ProjectCardProps {
5 project: Project;
6}
7
8export default function ProjectCard({ project }: ProjectCardProps) {
9 const [showWarning, setShowWarning] = useState(false);
10 const [showInfrastructureInfo, setShowInfrastructureInfo] = useState(false);
11 const getLinkIcon = (kind: string, url?: string) => {
12 // Special case: Show Bluesky icon for social links on bsky.app
13 if (kind === 'social' && url?.includes('bsky.app')) {
14 return (
15 <svg className="w-5 h-5" viewBox="0 0 600 530" fill="currentColor">
16 <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.4046-3.6904-11.832-3.7077-11.841-.0174.0088-1.1937 4.4368-3.7077 11.841-13.714 40.255-67.236 197.35-189.63 71.766-64.444-66.131-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/>
17 </svg>
18 );
19 }
20
21 // Special case: Show Mastodon icon for social links on Mastodon instances
22 if (kind === 'social' && (url?.includes('mastodon.social') || url?.includes('tech.lgbt') || url?.includes('social.funkwhale.audio'))) {
23 return (
24 <img
25 src="/logos/mastodon.svg"
26 alt="Mastodon"
27 className="w-5 h-5 object-contain"
28 />
29 );
30 }
31
32 switch (kind) {
33 case 'homepage':
34 return (
35 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
37 </svg>
38 );
39 case 'repo':
40 // Use GitHub icon only for GitHub URLs, otherwise use code icon
41 if (url?.includes('github.com')) {
42 return (
43 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
44 <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
45 </svg>
46 );
47 } else {
48 return (
49 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
51 </svg>
52 );
53 }
54 case 'docs':
55 return (
56 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
58 </svg>
59 );
60 case 'demo':
61 return (
62 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
63 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
64 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
65 </svg>
66 );
67 case 'spec':
68 return (
69 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
71 </svg>
72 );
73 case 'social':
74 return (
75 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
76 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
77 </svg>
78 );
79 default:
80 return (
81 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
83 </svg>
84 );
85 }
86 };
87
88 const homepageLink = project.links?.find(link => link.kind === 'homepage')?.url;
89
90 return (
91 <div className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-gray-600 transition-colors h-full flex flex-col">
92 <div className="p-4 flex flex-col flex-grow space-y-3">
93 <div className="flex items-start justify-between">
94 <a
95 href={homepageLink}
96 target="_blank"
97 rel="noopener noreferrer"
98 className="flex items-center space-x-3 hover:opacity-80 transition-opacity cursor-pointer"
99 >
100 {project.logoUrl ? (
101 <img
102 src={project.logoUrl}
103 alt={`${project.name} logo`}
104 className="w-10 h-10 rounded-lg object-cover"
105 />
106 ) : (
107 <div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-600 to-purple-600" />
108 )}
109 <div>
110 <h3 className="text-lg font-semibold text-gray-100 flex items-center gap-1">
111 {project.name}
112 <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
114 </svg>
115 </h3>
116 {(project.domain || project.owner) && (
117 <p className="text-sm text-gray-400">
118 {project.domain || `github.com/${project.owner}`}
119 </p>
120 )}
121 </div>
122 </a>
123 <div className="flex items-center gap-2">
124 {project.hasIndependentInfrastructure && (
125 <div className="relative group">
126 <button
127 onClick={(e) => {
128 e.stopPropagation();
129 setShowInfrastructureInfo(!showInfrastructureInfo);
130 }}
131 className="p-1 -m-1 rounded hover:bg-gray-700 transition-colors sm:pointer-events-none"
132 aria-label="Independent infrastructure"
133 >
134 <svg
135 className="w-5 h-5 text-blue-400"
136 fill="currentColor"
137 viewBox="0 0 20 20"
138 >
139 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
140 </svg>
141 </button>
142 {showInfrastructureInfo && (
143 <>
144 <div
145 className="fixed inset-0 z-20 sm:hidden"
146 onClick={() => setShowInfrastructureInfo(false)}
147 />
148 <div className="absolute right-0 top-8 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg z-30 border border-gray-700 sm:hidden">
149 Runs fully independent infrastructure
150 </div>
151 </>
152 )}
153 <div className="hidden sm:block absolute right-0 top-6 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 border border-gray-700">
154 Runs fully independent infrastructure
155 </div>
156 </div>
157 )}
158 {project.type === 'semi-platform' && (
159 <div className="relative group">
160 <button
161 onClick={(e) => {
162 e.stopPropagation();
163 setShowWarning(!showWarning);
164 }}
165 className="p-1 -m-1 rounded hover:bg-gray-700 transition-colors sm:pointer-events-none"
166 aria-label="Warning information"
167 >
168 <svg
169 className="w-5 h-5 text-yellow-500"
170 fill="currentColor"
171 viewBox="0 0 20 20"
172 >
173 <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
174 </svg>
175 </button>
176 {showWarning && (
177 <>
178 <div
179 className="fixed inset-0 z-20 sm:hidden"
180 onClick={() => setShowWarning(false)}
181 />
182 <div className="absolute right-0 top-8 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg z-30 border border-gray-700 sm:hidden">
183 Has not implemented open-source platform-based AT Protocol lexicon
184 </div>
185 </>
186 )}
187 <div className="hidden sm:block absolute right-0 top-6 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 border border-gray-700">
188 Has not implemented open-source platform-based AT Protocol lexicon
189 </div>
190 </div>
191 )}
192 </div>
193 </div>
194
195 {project.bannerUrl && (
196 <div className="h-32 -mx-4 px-4">
197 <img
198 src={project.bannerUrl}
199 alt={`${project.name} banner`}
200 className="w-full h-full object-cover rounded-lg"
201 />
202 </div>
203 )}
204
205 <p className="text-gray-300 text-sm">
206 {project.description}
207 </p>
208
209 <div className="flex flex-wrap gap-2">
210 {project.tags.map(tag => (
211 <span key={tag} className="px-2 py-1 bg-gray-700 text-gray-300 rounded-full text-xs">
212 {tag}
213 </span>
214 ))}
215 </div>
216
217 <div className="flex-grow"></div>
218
219 {project.links && project.links.length > 0 && (
220 <div className="flex gap-2 pt-2 border-t border-gray-700">
221 {project.links.map((link, index) => (
222 <a
223 key={index}
224 href={link.url}
225 target="_blank"
226 rel="noopener noreferrer"
227 title={link.kind}
228 className="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-400 hover:text-gray-200 transition-colors"
229 >
230 {getLinkIcon(link.kind, link.url)}
231 </a>
232 ))}
233 </div>
234 )}
235 </div>
236 </div>
237 );
238}