ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

use shared card component for history and results

byarielm.fyi 6cd4d622 f8aeb6af

verified
Changed files
+144 -73
dist
src
+2 -2
dist/index.html
··· 26 26 content="black-translucent" 27 27 /> 28 28 <title>ATLast: Find Your People in the ATmosphere</title> 29 - <script type="module" crossorigin src="/assets/index-o15l4btH.js"></script> 30 - <link rel="stylesheet" crossorigin href="/assets/index-Bj_zheer.css"> 29 + <script type="module" crossorigin src="/assets/index-DCjXuvAz.js"></script> 30 + <link rel="stylesheet" crossorigin href="/assets/index-BxAsnHb8.css"> 31 31 </head> 32 32 <body> 33 33 <div id="root"></div>
+56 -45
src/components/HistoryTab.tsx
··· 10 10 import SetupPrompt from "./common/SetupPrompt"; 11 11 import Card from "./common/Card"; 12 12 import Badge from "./common/Badge"; 13 + import CardItem from "./common/CardItem"; 13 14 14 15 interface HistoryTabProps { 15 16 uploads: UploadType[]; ··· 93 94 <Card 94 95 key={upload.uploadId} 95 96 variant="upload" 96 - onClick={() => onLoadUpload(upload.uploadId)} 97 - className="w-full flex items-start space-x-4 p-4" 97 + className="w-full" 98 98 > 99 - <div 100 - className={`w-10 h-10 bg-gradient-to-r ${getPlatformColor(upload.sourcePlatform)} rounded-xl flex items-center justify-center flex-shrink-0 shadow-md`} 101 - > 102 - <Sparkles className="w-6 h-6 text-white" /> 103 - </div> 104 - <div className="flex-1 min-w-0"> 105 - <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 mb-1"> 106 - <div className="font-semibold text-purple-950 dark:text-cyan-50 capitalize leading-tight"> 107 - {upload.sourcePlatform} 108 - </div> 109 - <div className="flex items-center gap-2 flex-shrink-0"> 110 - <span className="text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0"> 111 - {upload.matchedUsers}{" "} 112 - {upload.matchedUsers === 1 ? "match" : "matches"} 113 - </span> 114 - </div> 115 - </div> 116 - {destApp && ( 117 - <a 118 - href={destApp.link} 119 - target="_blank" 120 - rel="noopener noreferrer" 121 - onClick={(e) => e.stopPropagation()} 122 - className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight flex items-center space-x-1 w-fit" 99 + <CardItem 100 + padding="p-4" 101 + badgeIndentClass="sm:pl-[56px]" 102 + onClick={() => onLoadUpload(upload.uploadId)} 103 + avatar={ 104 + <div 105 + className={`w-10 h-10 bg-gradient-to-r ${getPlatformColor(upload.sourcePlatform)} rounded-xl flex items-center justify-center flex-shrink-0 shadow-md`} 123 106 > 124 - <span>{destApp.action} on</span> 107 + <Sparkles className="w-6 h-6 text-white" /> 108 + </div> 109 + } 110 + content={ 111 + <> 112 + <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2"> 113 + <div className="font-semibold text-purple-950 dark:text-cyan-50 capitalize leading-tight"> 114 + {upload.sourcePlatform} 115 + </div> 116 + <div className="flex items-center gap-2 flex-shrink-0"> 117 + <span className="text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0"> 118 + {upload.matchedUsers}{" "} 119 + {upload.matchedUsers === 1 ? "match" : "matches"} 120 + </span> 121 + </div> 122 + </div> 123 + {destApp && ( 124 + <a 125 + href={destApp.link} 126 + target="_blank" 127 + rel="noopener noreferrer" 128 + onClick={(e) => e.stopPropagation()} 129 + className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight flex items-center space-x-1 w-fit" 130 + > 131 + <span>{destApp.action} on</span> 125 132 126 - <FaviconIcon 127 - url={destApp.icon} 128 - alt={destApp.name} 129 - className="w-3 h-3 mb-0.5 flex-shrink-0" 130 - /> 133 + <FaviconIcon 134 + url={destApp.icon} 135 + alt={destApp.name} 136 + className="w-3 h-3 mb-0.5 flex-shrink-0" 137 + /> 131 138 132 - <span>{destApp.name}</span> 133 - </a> 134 - )} 135 - <div className="flex items-center flex-wrap gap-2 py-1.5 sm:ml-0 -ml-14"> 136 - <Badge variant="info"> 137 - {upload.totalUsers}{" "} 138 - {upload.totalUsers === 1 ? "user found" : "users found"} 139 - </Badge> 140 - <Badge variant="info"> 141 - Uploaded {formatRelativeTime(upload.createdAt)} 142 - </Badge> 143 - </div> 144 - </div> 139 + <span>{destApp.name}</span> 140 + </a> 141 + )} 142 + </> 143 + } 144 + badges={ 145 + <> 146 + <Badge variant="info"> 147 + {upload.totalUsers}{" "} 148 + {upload.totalUsers === 1 ? "user found" : "users found"} 149 + </Badge> 150 + <Badge variant="info"> 151 + Uploaded {formatRelativeTime(upload.createdAt)} 152 + </Badge> 153 + </> 154 + } 155 + /> 145 156 </Card> 146 157 ); 147 158 })}
+23 -26
src/components/SearchResultCard.tsx
··· 8 8 import Badge from "./common/Badge"; 9 9 import { StatBadge } from "./common/Stats"; 10 10 import Card from "./common/Card"; 11 + import CardItem from "./common/CardItem"; 11 12 12 13 interface SearchResultCardProps { 13 14 result: SearchResult; ··· 28 29 onToggle: () => void; 29 30 }>(({ match, isSelected, isFollowed, currentAppName, onToggle }) => { 30 31 return ( 31 - <div className="p-3 cursor-pointer hover:scale-[1.01] transition-transform"> 32 - {/* Top row: Avatar, Name/Handle, Follow Button */} 33 - <div className="flex items-start gap-3 mb-2"> 32 + <CardItem 33 + padding="p-3" 34 + badgeIndentClass="sm:pl-[44px]" 35 + avatar={ 34 36 <AvatarWithFallback 35 37 avatar={match.avatar} 36 38 handle={match.handle || ""} 37 39 size="sm" 38 40 /> 39 - 40 - <div className="flex-1 min-w-0"> 41 + } 42 + content={ 43 + <> 41 44 {match.displayName && ( 42 45 <div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight"> 43 46 {match.displayName} ··· 51 54 > 52 55 @{match.handle} 53 56 </a> 54 - </div> 55 - 57 + </> 58 + } 59 + action={ 56 60 <FollowButton 57 61 isFollowed={isFollowed} 58 62 isSelected={isSelected} 59 63 onToggle={onToggle} 60 64 appName={currentAppName} 61 65 /> 62 - </div> 63 - 64 - {/* Stats/Badges - align with avatar on mobile (pl-[60px] = avatar width 48px + gap 12px) */} 65 - <div className="flex items-center flex-wrap gap-2 pl-0 md:pl-[60px]"> 66 - {typeof match.postCount === "number" && match.postCount > 0 && ( 67 - <StatBadge value={match.postCount} label="posts" /> 68 - )} 69 - {typeof match.followerCount === "number" && 70 - match.followerCount > 0 && ( 66 + } 67 + badges={ 68 + <> 69 + {typeof match.postCount === "number" && match.postCount > 0 && ( 70 + <StatBadge value={match.postCount} label="posts" /> 71 + )} 72 + {typeof match.followerCount === "number" && match.followerCount > 0 && ( 71 73 <StatBadge value={match.followerCount} label="followers" /> 72 74 )} 73 - <Badge variant="match">{match.matchScore}% match</Badge> 74 - </div> 75 - 76 - {/* Description - align with avatar on mobile */} 77 - {match.description && ( 78 - <div className="text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 pt-2 pl-0 md:pl-[60px]"> 79 - {match.description} 80 - </div> 81 - )} 82 - </div> 75 + <Badge variant="match">{match.matchScore}% match</Badge> 76 + </> 77 + } 78 + description={match.description} 79 + /> 83 80 ); 84 81 }); 85 82
+63
src/components/common/CardItem.tsx
··· 1 + import React from "react"; 2 + 3 + interface CardItemProps { 4 + avatar: React.ReactNode; 5 + content: React.ReactNode; 6 + action?: React.ReactNode; 7 + badges: React.ReactNode; 8 + description?: React.ReactNode; 9 + padding?: "p-3" | "p-4"; 10 + badgeIndentClass: string; // Responsive indent class, e.g., "sm:pl-[44px]" 11 + onClick?: () => void; 12 + } 13 + 14 + /** 15 + * Shared card item component for consistent layout structure. 16 + * 17 + * Structure: 18 + * - Top row: Avatar + Content + Optional Action (flex layout) 19 + * - Badge row: With responsive left indent 20 + * - Optional description row: With responsive left indent 21 + * 22 + * Used by SearchResultCard and HistoryTab for consistent spacing. 23 + */ 24 + const CardItem: React.FC<CardItemProps> = ({ 25 + avatar, 26 + content, 27 + action, 28 + badges, 29 + description, 30 + padding = "p-3", 31 + badgeIndentClass, 32 + onClick, 33 + }) => { 34 + return ( 35 + <div 36 + className={`${padding} cursor-pointer hover:scale-[1.01] transition-transform`} 37 + onClick={onClick} 38 + > 39 + {/* Top row: Avatar + Content + Action */} 40 + <div className="flex items-start gap-3 mb-1"> 41 + {avatar} 42 + <div className="flex-1 min-w-0">{content}</div> 43 + {action} 44 + </div> 45 + 46 + {/* Badges row - responsive indent based on avatar size */} 47 + <div className={`flex items-center flex-wrap gap-2 pl-0 ${badgeIndentClass}`}> 48 + {badges} 49 + </div> 50 + 51 + {/* Optional description - same responsive indent as badges */} 52 + {description && ( 53 + <div 54 + className={`text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 mt-1 pt-2 pl-1 ${badgeIndentClass}`} 55 + > 56 + {description} 57 + </div> 58 + )} 59 + </div> 60 + ); 61 + }; 62 + 63 + export default CardItem;