A project tracker for decentralized social media platforms, clients, and tools
at main 12 kB view raw
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}