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

component-ify. some things need fixing.

byarielm.fyi f3c536d1 151f5336

verified
+24 -38
src/components/HistoryTab.tsx
··· 6 6 import { UploadHistorySkeleton } from "./common/LoadingSkeleton"; 7 7 import { getPlatformColor } from "../lib/utils/platform"; 8 8 import { formatRelativeTime } from "../lib/utils/date"; 9 + import EmptyState from "./common/EmptyState"; 10 + import SetupPrompt from "./common/SetupPrompt"; 11 + import Card from "./common/Card"; 12 + import Badge from "./common/Badge"; 9 13 10 14 interface HistoryTabProps { 11 15 uploads: UploadType[]; ··· 33 37 <div className="p-6"> 34 38 {/* Setup Assistant Banner - Only show if wizard not completed */} 35 39 {!wizardCompleted && ( 36 - <div className="bg-firefly-banner-dark dark:bg-firefly-banner-dark rounded-2xl p-6 text-white mb-3"> 37 - <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4"> 38 - <div className="flex-1"> 39 - <h2 className="text-2xl font-bold mb-2"> 40 - Need help getting started? 41 - </h2> 42 - <p className="text-white"> 43 - Run the setup assistant to configure your preferences in 44 - minutes. 45 - </p> 46 - </div> 47 - <button 48 - onClick={onShowWizard} 49 - className="bg-white text-slate-900 px-6 py-3 rounded-xl font-semibold hover:bg-slate-100 transition-all flex items-center space-x-2 whitespace-nowrap shadow-lg" 50 - > 51 - <span>Start Setup</span> 52 - <ChevronRight className="w-4 h-4" /> 53 - </button> 54 - </div> 55 - </div> 40 + <SetupPrompt 41 + variant="banner" 42 + isCompleted={wizardCompleted} 43 + onShowWizard={onShowWizard} 44 + /> 56 45 )} 57 46 58 47 <div className="flex items-center space-x-3 mb-4"> ··· 68 57 69 58 {/* Data Storage Disabled Notice */} 70 59 {!userSettings.saveData && ( 71 - <div className="mb-4 p-4 border-2 rounded-xl border-orange-650/50 dark:border-amber-400/50 bg-purple-100/50 dark:bg-slate-900/50"> 60 + <Card className="mb-4 p-4 border-orange-650/50 dark:border-amber-400/50 bg-purple-100/50 dark:bg-slate-900/50"> 72 61 <div className="flex items-start space-x-3"> 73 62 <Database className="w-5 h-5 text-orange-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> 74 63 <div> ··· 81 70 </p> 82 71 </div> 83 72 </div> 84 - </div> 73 + </Card> 85 74 )} 86 75 87 76 {isLoading ? ( ··· 91 80 ))} 92 81 </div> 93 82 ) : uploads.length === 0 ? ( 94 - <div className="text-center py-12"> 95 - <Upload className="w-16 h-16 text-purple-900 dark:text-cyan-100 mx-auto mb-4" /> 96 - <p className="text-purple-750 dark:text-cyan-250 font-medium"> 97 - No previous uploads yet 98 - </p> 99 - <p className="text-sm text-purple-950 dark:text-cyan-50 mt-2"> 100 - Upload your first file to get started 101 - </p> 102 - </div> 83 + <EmptyState 84 + icon={Upload} 85 + title="No previous uploads yet" 86 + message="Upload your first file to get started" 87 + /> 103 88 ) : ( 104 89 <div className="space-y-3"> 105 90 {uploads.map((upload) => { ··· 110 95 ] 111 96 ]; 112 97 return ( 113 - <div 98 + <Card 114 99 key={upload.uploadId} 100 + variant="upload" 115 101 onClick={() => onLoadUpload(upload.uploadId)} 116 - className="w-full flex items-start space-x-4 p-4 bg-purple-100/20 dark:bg-slate-900/50 hover:bg-purple-100/40 dark:hover:bg-slate-900/70 rounded-xl transition-all text-left border-2 border-orange-650/50 dark:border-amber-400/50 hover:border-orange-500 dark:hover:border-amber-400 shadow-md hover:shadow-lg cursor-pointer" 102 + className="w-full flex items-start space-x-4 p-4" 117 103 > 118 104 <div 119 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`} ··· 152 138 </a> 153 139 )} 154 140 <div className="flex items-center flex-wrap gap-2 py-1.5 sm:ml-0 -ml-14"> 155 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 141 + <Badge variant="info"> 156 142 {upload.totalUsers}{" "} 157 143 {upload.totalUsers === 1 ? "user found" : "users found"} 158 - </span> 159 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 144 + </Badge> 145 + <Badge variant="info"> 160 146 Uploaded {formatDate(upload.createdAt)} 161 - </span> 147 + </Badge> 162 148 </div> 163 149 </div> 164 - </div> 150 + </Card> 165 151 ); 166 152 })} 167 153 </div>
+9 -11
src/components/SearchResultCard.tsx
··· 5 5 import type { AtprotoAppId } from "../types/settings"; 6 6 import AvatarWithFallback from "./common/AvatarWithFallback"; 7 7 import FollowButton from "./common/FollowButton"; 8 + import Badge from "./common/Badge"; 9 + import { StatBadge } from "./common/Stats"; 10 + import Card from "./common/Card"; 8 11 9 12 interface SearchResultCardProps { 10 13 result: SearchResult; ··· 51 54 52 55 <div className="flex items-center flex-wrap gap-2"> 53 56 {typeof match.postCount === "number" && match.postCount > 0 && ( 54 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 55 - {match.postCount.toLocaleString()} posts 56 - </span> 57 + <StatBadge value={match.postCount} label="posts" /> 57 58 )} 58 59 {typeof match.followerCount === "number" && 59 60 match.followerCount > 0 && ( 60 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 61 - {match.followerCount.toLocaleString()} followers 62 - </span> 61 + <StatBadge value={match.followerCount} label="followers" /> 63 62 )} 64 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 65 - {match.matchScore}% match 66 - </span> 63 + <Badge variant="match">{match.matchScore}% match</Badge> 67 64 </div> 68 65 69 66 {match.description && ( ··· 114 111 const hasMoreMatches = result.atprotoMatches.length > 1; 115 112 116 113 return ( 117 - <div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30"> 114 + <Card variant="result"> 118 115 {/* Source User */} 119 116 <div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 120 117 <div className="flex justify-between gap-2 items-center"> ··· 175 172 )} 176 173 </div> 177 174 )} 178 - </div> 175 + </Card> 179 176 ); 180 177 }, 181 178 ); 179 + 182 180 SearchResultCard.displayName = "SearchResultCard"; 183 181 export default SearchResultCard;
+21 -25
src/components/SetupWizard.tsx
··· 3 3 import { PLATFORMS } from "../config/platforms"; 4 4 import { ATPROTO_APPS } from "../config/atprotoApps"; 5 5 import type { UserSettings, PlatformDestinations } from "../types/settings"; 6 + import ProgressBar from "./common/ProgressBar"; 7 + import Card from "./common/Card"; 8 + import PlatformBadge from "./common/PlatformBadge"; 6 9 7 10 interface SetupWizardProps { 8 11 isOpen: boolean; ··· 70 73 71 74 return ( 72 75 <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> 73 - <div className="bg-white dark:bg-slate-900 backdrop-blur-xl rounded-2xl max-w-2xl w-full shadow-2xl max-h-[90vh] flex flex-col border-2 border-cyan-500/30 dark:border-purple-500/30"> 76 + <Card 77 + variant="wizard" 78 + className="max-w-2xl w-full max-h-[90vh] flex flex-col" 79 + > 74 80 {/* Header */} 75 81 <div className="px-6 py-4 border-b-2 border-cyan-500/30 dark:border-purple-500/30 flex-shrink-0"> 76 82 <div className="flex items-center justify-between mb-3"> ··· 90 96 </button> 91 97 </div> 92 98 {/* Progress */} 93 - <div className="flex items-center space-x-2"> 94 - {wizardSteps.map((step, idx) => ( 95 - <div key={idx} className="flex-1"> 96 - <div 97 - className={`h-2 rounded-full transition-all ${ 98 - idx <= wizardStep 99 - ? "bg-orange-500" 100 - : "bg-cyan-500/30 dark:bg-purple-500/30" 101 - }`} 102 - /> 103 - </div> 104 - ))} 105 - </div> 99 + <ProgressBar 100 + current={wizardStep + 1} 101 + total={wizardSteps.length} 102 + variant="wizard" 103 + className="flex items-center space-x-2" 104 + /> 106 105 <div className="mt-2 text-sm text-purple-750 dark:text-cyan-250"> 107 106 Step {wizardStep + 1} of {wizardSteps.length}:{" "} 108 107 {wizardSteps[wizardStep].title} ··· 136 135 </p> 137 136 <div className="grid grid-cols-3 gap-3 mt-3"> 138 137 {Object.entries(PLATFORMS).map(([key, p]) => { 139 - const Icon = p.icon; 140 138 const isSelected = selectedPlatforms.has(key); 141 139 return ( 142 140 <button ··· 153 151 <Check className="w-4 h-4 text-white dark:text-slate-900" /> 154 152 </div> 155 153 )} 156 - <Icon className="w-8 h-8 mx-auto mb-2 text-purple-750 dark:text-cyan-250" /> 154 + <PlatformBadge 155 + platformKey={key} 156 + showName={false} 157 + size="lg" 158 + className="justify-center mb-2" 159 + /> 157 160 <div className="text-sm font-medium text-purple-950 dark:text-cyan-50"> 158 161 {p.name} 159 162 </div> ··· 183 186 </p> 184 187 <div className="space-y-4 mt-3"> 185 188 {platformsToShow.map(([key, p]) => { 186 - const Icon = p.icon; 187 189 return ( 188 190 <div 189 191 key={key} 190 192 className="flex items-center px-3 max-w-lg mx-sm border-cyan-500/30 dark:border-purple-500/30" 191 193 > 192 - <div className="flex space-x-3"> 193 - <Icon className="w-6 h-6 text-purple-950 dark:text-cyan-50" /> 194 - <span className="font-medium text-purple-950 dark:text-cyan-50"> 195 - {p.name} 196 - </span> 197 - </div> 194 + <PlatformBadge platformKey={key} size="sm" /> 198 195 <select 199 196 value={ 200 197 platformDestinations[ ··· 221 218 </div> 222 219 </div> 223 220 )} 224 - 225 221 {wizardStep === 3 && ( 226 222 <div className="space-y-3"> 227 223 <div> ··· 373 369 )} 374 370 </button> 375 371 </div> 376 - </div> 372 + </Card> 377 373 </div> 378 374 ); 379 375 }
+14 -25
src/components/UploadTab.tsx
··· 1 1 import { Settings } from "lucide-react"; 2 2 import { useRef } from "react"; 3 3 import PlatformSelector from "../components/PlatformSelector"; 4 + import SetupPrompt from "./common/SetupPrompt"; 5 + import Section from "./common/Section"; 4 6 5 7 interface UploadTabProps { 6 8 wizardCompleted: boolean; ··· 28 30 }; 29 31 30 32 return ( 31 - <div className="p-6"> 32 - {/* Upload Section */} 33 + <Section 34 + title="Upload Following Data" 35 + description="Find your people on the ATmosphere" 36 + action={ 37 + <SetupPrompt 38 + variant="button" 39 + isCompleted={wizardCompleted} 40 + onShowWizard={onShowWizard} 41 + /> 42 + } 43 + > 33 44 <div className="space-y-3"> 34 - <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4"> 35 - <div className="flex items-center space-x-3 mb-2 sm:mb-0"> 36 - <div> 37 - <h2 className="text-xl font-bold text-purple-950 dark:text-cyan-50"> 38 - Upload Following Data 39 - </h2> 40 - <p className="text-sm text-purple-750 dark:text-cyan-250"> 41 - Find your people on the ATmosphere 42 - </p> 43 - </div> 44 - </div> 45 - {!wizardCompleted && ( 46 - <button 47 - onClick={onShowWizard} 48 - className="text-md text-orange-650 hover:text-orange-500 dark:text-amber-400 dark:hover:text-amber-300 font-medium transition-colors flex items-center space-x-1" 49 - > 50 - <Settings className="w-4 h-4" /> 51 - <span>Configure</span> 52 - </button> 53 - )} 54 - </div> 55 - 56 45 <PlatformSelector onPlatformSelect={handlePlatformSelect} /> 57 46 58 47 <input ··· 65 54 aria-label="Upload following data file" 66 55 /> 67 56 </div> 68 - </div> 57 + </Section> 69 58 ); 70 59 }
+33
src/components/common/Badge.tsx
··· 1 + import React from "react"; 2 + 3 + export type BadgeVariant = "stat" | "match" | "info" | "platform" | "status"; 4 + 5 + interface BadgeProps { 6 + children: React.ReactNode; 7 + variant?: BadgeVariant; 8 + className?: string; 9 + } 10 + 11 + const Badge: React.FC<BadgeProps> = ({ 12 + children, 13 + variant = "info", 14 + className = "", 15 + }) => { 16 + const baseStyles = "text-xs px-2 py-0.5 rounded-full font-medium"; 17 + 18 + const variantStyles: Record<BadgeVariant, string> = { 19 + stat: "bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50", 20 + match: "bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50", 21 + info: "bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50", 22 + platform: "bg-purple-100 dark:bg-cyan-900 text-purple-600 dark:text-cyan-400", 23 + status: "bg-orange-650/50 dark:bg-amber-400/50 text-orange-650 dark:text-amber-400", 24 + }; 25 + 26 + return ( 27 + <span className={`${baseStyles} ${variantStyles[variant]} ${className}`}> 28 + {children} 29 + </span> 30 + ); 31 + }; 32 + 33 + export default Badge;
+49
src/components/common/Card.tsx
··· 1 + import React from "react"; 2 + 3 + export type CardVariant = 4 + | "default" 5 + | "result" 6 + | "upload" 7 + | "wizard" 8 + | "interactive"; 9 + 10 + interface CardProps { 11 + children: React.ReactNode; 12 + variant?: CardVariant; 13 + className?: string; 14 + onClick?: () => void; 15 + } 16 + 17 + const Card: React.FC<CardProps> = ({ 18 + children, 19 + variant = "default", 20 + className = "", 21 + onClick, 22 + }) => { 23 + const baseStyles = 24 + "rounded-2xl border-2 border-cyan-500/30 dark:border-purple-500/30"; 25 + 26 + const variantStyles: Record<CardVariant, string> = { 27 + default: "bg-white/50 dark:bg-slate-900/50 backdrop-blur-xl", 28 + result: "bg-white/50 dark:bg-slate-900/50 shadow-sm overflow-hidden", 29 + upload: 30 + "bg-purple-100/20 dark:bg-slate-900/50 hover:bg-purple-100/40 dark:hover:bg-slate-900/70 border-orange-650/50 dark:border-amber-400/50 hover:border-orange-500 dark:hover:border-amber-400 shadow-md hover:shadow-lg transition-all", 31 + wizard: "bg-white dark:bg-slate-900 shadow-2xl", 32 + interactive: "hover:scale-105 transition-all shadow-lg cursor-pointer", 33 + }; 34 + 35 + const clickableStyles = onClick 36 + ? "cursor-pointer hover:scale-[1.01] transition-transform" 37 + : ""; 38 + 39 + return ( 40 + <div 41 + className={`${baseStyles} ${variantStyles[variant]} ${clickableStyles} ${className}`} 42 + onClick={onClick} 43 + > 44 + {children} 45 + </div> 46 + ); 47 + }; 48 + 49 + export default Card;
+30
src/components/common/EmptyState.tsx
··· 1 + import React from "react"; 2 + import { LucideIcon } from "lucide-react"; 3 + 4 + interface EmptyStateProps { 5 + icon: LucideIcon; 6 + title: string; 7 + message?: string; 8 + className?: string; 9 + } 10 + 11 + const EmptyState: React.FC<EmptyStateProps> = ({ 12 + icon: Icon, 13 + title, 14 + message, 15 + className = "", 16 + }) => { 17 + return ( 18 + <div className={`text-center py-12 ${className}`}> 19 + <Icon className="w-16 h-16 text-purple-900 dark:text-cyan-100 mx-auto mb-4" /> 20 + <p className="text-purple-750 dark:text-cyan-250 font-medium">{title}</p> 21 + {message && ( 22 + <p className="text-sm text-purple-950 dark:text-cyan-50 mt-2"> 23 + {message} 24 + </p> 25 + )} 26 + </div> 27 + ); 28 + }; 29 + 30 + export default EmptyState;
+15 -4
src/components/common/IconButton.tsx
··· 4 4 interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 5 5 icon: LucideIcon; 6 6 label: string; 7 + showLabel?: boolean; 7 8 variant?: "primary" | "secondary" | "ghost"; 8 9 size?: "sm" | "md" | "lg"; 9 10 } ··· 13 14 { 14 15 icon: Icon, 15 16 label, 17 + showLabel = false, 16 18 variant = "ghost", 17 19 size = "md", 18 20 className = "", ··· 21 23 ref, 22 24 ) => { 23 25 const baseStyles = 24 - "inline-flex items-center justify-center rounded-full transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"; 26 + "inline-flex items-center justify-center transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"; 25 27 26 28 const variants = { 27 29 primary: ··· 33 35 }; 34 36 35 37 const sizes = { 36 - sm: "p-1.5", 37 - md: "p-2", 38 - lg: "p-3", 38 + sm: showLabel ? "px-3 py-1.5 rounded-lg" : "p-1.5 rounded-full", 39 + md: showLabel ? "px-4 py-2 rounded-xl" : "p-2 rounded-full", 40 + lg: showLabel ? "px-6 py-3 rounded-xl" : "p-3 rounded-full", 39 41 }; 40 42 41 43 const iconSizes = { ··· 44 46 lg: "w-6 h-6", 45 47 }; 46 48 49 + const textSizes = { 50 + sm: "text-sm", 51 + md: "text-base", 52 + lg: "text-lg", 53 + }; 54 + 47 55 return ( 48 56 <button 49 57 ref={ref} ··· 53 61 {...props} 54 62 > 55 63 <Icon className={iconSizes[size]} /> 64 + {showLabel && ( 65 + <span className={`ml-2 font-medium ${textSizes[size]}`}>{label}</span> 66 + )} 56 67 </button> 57 68 ); 58 69 },
+52
src/components/common/PlatformBadge.tsx
··· 1 + import React from "react"; 2 + import { getPlatform } from "../../lib/utils/platform"; 3 + 4 + interface PlatformBadgeProps { 5 + platformKey: string; 6 + showIcon?: boolean; 7 + showName?: boolean; 8 + size?: "sm" | "md" | "lg"; 9 + className?: string; 10 + } 11 + 12 + const PlatformBadge: React.FC<PlatformBadgeProps> = ({ 13 + platformKey, 14 + showIcon = true, 15 + showName = true, 16 + size = "md", 17 + className = "", 18 + }) => { 19 + const platform = getPlatform(platformKey); 20 + const Icon = platform.icon; 21 + 22 + const iconSizes = { 23 + sm: "w-4 h-4", 24 + md: "w-6 h-6", 25 + lg: "w-8 h-8", 26 + }; 27 + 28 + const textSizes = { 29 + sm: "text-sm", 30 + md: "text-base", 31 + lg: "text-lg", 32 + }; 33 + 34 + return ( 35 + <div className={`flex items-center space-x-2 ${className}`}> 36 + {showIcon && ( 37 + <Icon 38 + className={`${iconSizes[size]} text-purple-950 dark:text-cyan-50`} 39 + /> 40 + )} 41 + {showName && ( 42 + <span 43 + className={`font-medium text-purple-950 dark:text-cyan-50 capitalize ${textSizes[size]}`} 44 + > 45 + {platform.name} 46 + </span> 47 + )} 48 + </div> 49 + ); 50 + }; 51 + 52 + export default PlatformBadge;
+59
src/components/common/ProgressBar.tsx
··· 1 + import React from "react"; 2 + 3 + export type ProgressVariant = "search" | "wizard" | "default"; 4 + 5 + interface ProgressBarProps { 6 + current: number; 7 + total: number; 8 + variant?: ProgressVariant; 9 + className?: string; 10 + showLabel?: boolean; 11 + } 12 + 13 + const ProgressBar: React.FC<ProgressBarProps> = ({ 14 + current, 15 + total, 16 + variant = "default", 17 + className = "", 18 + showLabel = false, 19 + }) => { 20 + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; 21 + 22 + const containerStyles: Record<ProgressVariant, string> = { 23 + default: "w-full bg-slate-200 dark:bg-slate-700 rounded-full h-3", 24 + search: "w-full bg-slate-200 dark:bg-slate-700 rounded-full h-3", 25 + wizard: "h-2 rounded-full", 26 + }; 27 + 28 + const barStyles: Record<ProgressVariant, string> = { 29 + default: 30 + "bg-firefly-banner dark:bg-firefly-banner-dark h-full rounded-full transition-all", 31 + search: 32 + "bg-firefly-banner dark:bg-firefly-banner-dark h-full rounded-full transition-all", 33 + wizard: "bg-orange-500 h-full rounded-full transition-all", 34 + }; 35 + 36 + return ( 37 + <div className={className}> 38 + {showLabel && ( 39 + <div className="text-sm text-purple-750 dark:text-cyan-250 mb-2"> 40 + {percentage}% complete 41 + </div> 42 + )} 43 + <div 44 + className={containerStyles[variant]} 45 + role="progressbar" 46 + aria-valuenow={percentage} 47 + aria-valuemin={0} 48 + aria-valuemax={100} 49 + > 50 + <div 51 + className={barStyles[variant]} 52 + style={{ width: `${percentage}%` }} 53 + /> 54 + </div> 55 + </div> 56 + ); 57 + }; 58 + 59 + export default ProgressBar;
+46
src/components/common/Section.tsx
··· 1 + import React from "react"; 2 + 3 + interface SectionProps { 4 + title: string; 5 + description?: string; 6 + children: React.ReactNode; 7 + divider?: boolean; 8 + className?: string; 9 + action?: React.ReactNode; 10 + } 11 + 12 + const Section: React.FC<SectionProps> = ({ 13 + title, 14 + description, 15 + children, 16 + divider = false, 17 + className = "", 18 + action, 19 + }) => { 20 + const containerClasses = divider 21 + ? "p-6 border-b-2 border-cyan-500/30 dark:border-purple-500/30" 22 + : "p-6"; 23 + 24 + return ( 25 + <div className={`${containerClasses} ${className}`}> 26 + <div className="flex items-start justify-between mb-4"> 27 + <div className="flex items-center space-x-3"> 28 + <div> 29 + <h2 className="text-xl font-bold text-purple-950 dark:text-cyan-50"> 30 + {title} 31 + </h2> 32 + {description && ( 33 + <p className="text-sm text-purple-750 dark:text-cyan-250"> 34 + {description} 35 + </p> 36 + )} 37 + </div> 38 + </div> 39 + {action && <div>{action}</div>} 40 + </div> 41 + {children} 42 + </div> 43 + ); 44 + }; 45 + 46 + export default Section;
+58
src/components/common/SetupPrompt.tsx
··· 1 + import React from "react"; 2 + import { Settings, ChevronRight } from "lucide-react"; 3 + 4 + export type SetupPromptVariant = "banner" | "button"; 5 + 6 + interface SetupPromptProps { 7 + variant?: SetupPromptVariant; 8 + isCompleted: boolean; 9 + onShowWizard: () => void; 10 + className?: string; 11 + } 12 + 13 + const SetupPrompt: React.FC<SetupPromptProps> = ({ 14 + variant = "button", 15 + isCompleted, 16 + onShowWizard, 17 + className = "", 18 + }) => { 19 + if (isCompleted) return null; 20 + 21 + if (variant === "banner") { 22 + return ( 23 + <div 24 + className={`bg-firefly-banner-dark dark:bg-firefly-banner-dark rounded-2xl p-6 text-white mb-3 ${className}`} 25 + > 26 + <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4"> 27 + <div className="flex-1"> 28 + <h2 className="text-2xl font-bold mb-2"> 29 + Need help getting started? 30 + </h2> 31 + <p className="text-white"> 32 + Run the setup assistant to configure your preferences in minutes. 33 + </p> 34 + </div> 35 + <button 36 + onClick={onShowWizard} 37 + className="bg-white text-slate-900 px-6 py-3 rounded-xl font-semibold hover:bg-slate-100 transition-all flex items-center space-x-2 whitespace-nowrap shadow-lg" 38 + > 39 + <span>Start Setup</span> 40 + <ChevronRight className="w-4 h-4" /> 41 + </button> 42 + </div> 43 + </div> 44 + ); 45 + } 46 + 47 + return ( 48 + <button 49 + onClick={onShowWizard} 50 + className={`text-md text-orange-650 hover:text-orange-500 dark:text-amber-400 dark:hover:text-amber-300 font-medium transition-colors flex items-center space-x-1 ${className}`} 51 + > 52 + <Settings className="w-4 h-4" /> 53 + <span>Configure</span> 54 + </button> 55 + ); 56 + }; 57 + 58 + export default SetupPrompt;
+82
src/components/common/Stats.tsx
··· 1 + import React from "react"; 2 + import Badge from "./Badge"; 3 + 4 + interface StatItemProps { 5 + value: number | string; 6 + label: string; 7 + variant?: "default" | "highlight" | "muted"; 8 + format?: boolean; 9 + } 10 + 11 + export const StatItem: React.FC<StatItemProps> = ({ 12 + value, 13 + label, 14 + variant = "default", 15 + format = true, 16 + }) => { 17 + const formattedValue = 18 + typeof value === "number" && format ? value.toLocaleString() : value; 19 + 20 + const textColors = { 21 + default: "text-slate-900 dark:text-slate-100", 22 + highlight: "text-orange-500 dark:text-amber-400", 23 + muted: "text-slate-600 dark:text-slate-400", 24 + }; 25 + 26 + return ( 27 + <div> 28 + <div 29 + className={`text-2xl font-bold ${textColors[variant]}`} 30 + aria-label={`${formattedValue} ${label}`} 31 + > 32 + {formattedValue} 33 + </div> 34 + <div className="text-sm text-slate-700 dark:text-slate-300 font-medium"> 35 + {label} 36 + </div> 37 + </div> 38 + ); 39 + }; 40 + 41 + interface StatsGroupProps { 42 + stats: Array<{ 43 + value: number | string; 44 + label: string; 45 + variant?: "default" | "highlight" | "muted"; 46 + }>; 47 + className?: string; 48 + } 49 + 50 + export const StatsGroup: React.FC<StatsGroupProps> = ({ 51 + stats, 52 + className = "", 53 + }) => { 54 + return ( 55 + <div className={`grid gap-4 text-center ${className}`}> 56 + {stats.map((stat, index) => ( 57 + <StatItem key={index} {...stat} /> 58 + ))} 59 + </div> 60 + ); 61 + }; 62 + 63 + interface StatBadgeProps { 64 + value: number | string; 65 + label: string; 66 + format?: boolean; 67 + } 68 + 69 + export const StatBadge: React.FC<StatBadgeProps> = ({ 70 + value, 71 + label, 72 + format = true, 73 + }) => { 74 + const formattedValue = 75 + typeof value === "number" && format ? value.toLocaleString() : value; 76 + 77 + return ( 78 + <Badge variant="stat"> 79 + {formattedValue} {label} 80 + </Badge> 81 + ); 82 + };
+33
src/components/common/index.tsx
··· 1 + // Re-export all common components for easier imports 2 + export { default as Avatar } from "./AvatarWithFallback"; 3 + export { default as Badge } from "./Badge"; 4 + export { default as Button } from "./Button"; 5 + export { default as Card } from "./Card"; 6 + export { default as EmptyState } from "./EmptyState"; 7 + export { default as ErrorBoundary } from "./ErrorBoundary"; 8 + export { default as FollowButton } from "./FollowButton"; 9 + export { default as IconButton } from "./IconButton"; 10 + export { default as Notification } from "./Notification"; 11 + export { default as NotificationContainer } from "./NotificationContainer"; 12 + export { default as PlatformBadge } from "./PlatformBadge"; 13 + export { default as ProgressBar } from "./ProgressBar"; 14 + export { default as Section } from "./Section"; 15 + export { default as SetupPrompt } from "./SetupPrompt"; 16 + export { default as Skeleton } from "./Skeleton"; 17 + 18 + // Export Stats components 19 + export { StatItem, StatBadge, StatsGroup } from "./Stats"; 20 + 21 + // Export Skeletons 22 + export { 23 + SearchResultSkeleton, 24 + UploadHistorySkeleton, 25 + ProfileSkeleton, 26 + } from "./LoadingSkeleton"; 27 + 28 + // Export types 29 + export type { BadgeVariant } from "./Badge"; 30 + export type { CardVariant } from "./Card"; 31 + export type { SetupPromptVariant } from "./SetupPrompt"; 32 + export type { ProgressVariant } from "./ProgressBar"; 33 + export type { NotificationType } from "./Notification";
+28 -59
src/pages/Loading.tsx
··· 1 1 import AppHeader from "../components/AppHeader"; 2 2 import { SearchResultSkeleton } from "../components/common/LoadingSkeleton"; 3 3 import { getPlatform } from "../lib/utils/platform"; 4 + import { StatsGroup } from "../components/common/Stats"; 5 + import ProgressBar from "../components/common/ProgressBar"; 6 + import PlatformBadge from "../components/common/PlatformBadge"; 4 7 5 8 interface atprotoSession { 6 9 did: string; ··· 42 45 onToggleMotion, 43 46 }: LoadingPageProps) { 44 47 const platform = getPlatform(sourcePlatform); 45 - const PlatformIcon = platform.icon; 48 + 49 + const statsData = [ 50 + { 51 + value: searchProgress.searched, 52 + label: "Searched", 53 + variant: "default" as const, 54 + }, 55 + { 56 + value: searchProgress.found, 57 + label: "Fireflies Found", 58 + variant: "highlight" as const, 59 + }, 60 + { value: searchProgress.total, label: "Total", variant: "muted" as const }, 61 + ]; 46 62 47 63 return ( 48 64 <div className="min-h-screen"> ··· 64 80 <div className="max-w-3xl mx-auto px-4 py-6"> 65 81 <div className="flex items-center justify-between"> 66 82 <div className="flex items-center space-x-4"> 67 - <div className="relative w-12 h-12"> 68 - <PlatformIcon className="w-10 h-10" /> 69 - </div> 83 + <PlatformBadge 84 + platformKey={sourcePlatform} 85 + showName={false} 86 + size="lg" 87 + /> 70 88 <div> 71 89 <h2 className="text-xl font-bold">Finding Your Fireflies</h2> 72 90 <p className="text-white text-sm"> ··· 87 105 {/* Progress Stats */} 88 106 <div className="bg-white/95 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30 backdrop-blur-sm"> 89 107 <div className="max-w-3xl mx-auto px-4 py-4"> 90 - <div className="grid grid-cols-3 gap-4 text-center mb-4"> 91 - <div> 92 - <div 93 - className="text-2xl font-bold text-slate-900 dark:text-slate-100" 94 - aria-label={`${searchProgress.searched} searched`} 95 - > 96 - {searchProgress.searched} 97 - </div> 98 - <div className="text-sm text-slate-700 dark:text-slate-300 font-medium"> 99 - Searched 100 - </div> 101 - </div> 102 - <div> 103 - <div 104 - className="text-2xl font-bold text-orange-500 dark:text-amber-400" 105 - aria-label={`${searchProgress.found} found`} 106 - > 107 - {searchProgress.found} 108 - </div> 109 - <div className="text-sm text-slate-700 dark:text-slate-300 font-medium"> 110 - Fireflies Found 111 - </div> 112 - </div> 113 - <div> 114 - <div 115 - className="text-2xl font-bold text-slate-600 dark:text-slate-400" 116 - aria-label={`${searchProgress.total} total`} 117 - > 118 - {searchProgress.total} 119 - </div> 120 - <div className="text-sm text-slate-700 dark:text-slate-300 font-medium"> 121 - Total 122 - </div> 123 - </div> 124 - </div> 108 + <StatsGroup stats={statsData} className="grid-cols-3 mb-4" /> 125 109 126 - <div 127 - className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-3" 128 - role="progressbar" 129 - aria-valuenow={ 130 - searchProgress.total > 0 131 - ? Math.round( 132 - (searchProgress.searched / searchProgress.total) * 100, 133 - ) 134 - : 0 135 - } 136 - aria-valuemin={0} 137 - aria-valuemax={100} 138 - > 139 - <div 140 - className="bg-firefly-banner dark:bg-firefly-banner-dark h-full rounded-full transition-all" 141 - style={{ 142 - width: `${searchProgress.total > 0 ? (searchProgress.searched / searchProgress.total) * 100 : 0}%`, 143 - }} 144 - /> 145 - </div> 110 + <ProgressBar 111 + current={searchProgress.searched} 112 + total={searchProgress.total} 113 + variant="search" 114 + /> 146 115 </div> 147 116 </div> 148 117
+7 -10
src/pages/Results.tsx
··· 6 6 import type { AtprotoAppId } from "../types/settings"; 7 7 import { getPlatform, getAtprotoApp } from "../lib/utils/platform"; 8 8 import VirtualizedResultsList from "../components/VirtualizedResultsList"; 9 + import Button from "../components/common/Button"; 9 10 10 11 interface atprotoSession { 11 12 did: string; ··· 163 164 {/* Action Buttons */} 164 165 <div className="bg-white/95 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30 sticky top-0 z-10 backdrop-blur-sm"> 165 166 <div className="max-w-3xl mx-auto px-4 py-3 flex space-x-2"> 166 - <button 167 - onClick={onSelectAll} 168 - className="flex-1 bg-orange-600 hover:bg-orange-400 text-white py-3 rounded-xl text-sm font-semibold transition-all shadow-md hover:shadow-lg" 169 - type="button" 170 - > 167 + <Button onClick={onSelectAll} variant="primary" className="flex-1"> 171 168 Select All 172 - </button> 173 - <button 169 + </Button> 170 + <Button 174 171 onClick={onDeselectAll} 175 - className="flex-1 bg-slate-600 dark:bg-slate-700 hover:bg-slate-700 dark:hover:bg-slate-600 text-white py-3 rounded-xl text-sm font-semibold transition-all shadow-md hover:shadow-lg focus:outline-none focus:ring-4 focus:ring-slate-400" 176 - type="button" 172 + variant="secondary" 173 + className="flex-1" 177 174 > 178 175 Clear 179 - </button> 176 + </Button> 180 177 </div> 181 178 </div> 182 179
+38 -59
src/pages/Settings.tsx
··· 2 2 import { PLATFORMS } from "../config/platforms"; 3 3 import { ATPROTO_APPS } from "../config/atprotoApps"; 4 4 import type { UserSettings, PlatformDestinations } from "../types/settings"; 5 + import Section from "../components/common/Section"; 6 + import Card from "../components/common/Card"; 7 + import Badge from "../components/common/Badge"; 8 + import PlatformBadge from "../components/common/PlatformBadge"; 5 9 6 10 interface SettingsPageProps { 7 11 userSettings: UserSettings; ··· 26 30 return ( 27 31 <div className="space-y-0"> 28 32 {/* Setup Assistant Section */} 29 - <div className="p-6 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 30 - <div className="flex items-center space-x-3 mb-4"> 31 - <div> 32 - <h2 className="text-xl font-bold text-purple-950 dark:text-cyan-50"> 33 - Setup Assistant 34 - </h2> 35 - <p className="text-sm text-purple-750 dark:text-cyan-250"> 36 - Quick configuration wizard 37 - </p> 38 - </div> 39 - </div> 40 - 41 - <button 33 + <Section 34 + title="Setup Assistant" 35 + description="Quick configuration wizard" 36 + divider 37 + > 38 + <Card 39 + variant="upload" 42 40 onClick={onOpenWizard} 43 - className="w-full flex items-start space-x-4 p-4 bg-purple-100/20 dark:bg-slate-900/50 hover:bg-purple-100/40 dark:hover:bg-slate-900/70 rounded-xl transition-all text-left border-2 border-orange-650/50 dark:border-amber-400/50 hover:border-orange-500 dark:hover:border-amber-400 shadow-md hover:shadow-lg" 41 + className="w-full flex items-start space-x-4 p-4 text-left" 44 42 > 45 43 <div className="w-12 h-12 bg-firefly-banner dark:bg-firefly-banner-dark rounded-xl flex items-center justify-center flex-shrink-0 shadow-md"> 46 44 <SettingsIcon className="w-6 h-6 text-white" /> ··· 56 54 </p> 57 55 </div> 58 56 <ChevronRight className="w-5 h-5 text-purple-500 dark:text-cyan-400 flex-shrink-0 self-center" /> 59 - </button> 57 + </Card> 60 58 61 59 {/* Current Configuration */} 62 60 <div className="mt-2 py-2 px-3"> ··· 68 66 <div className="text-purple-750 dark:text-cyan-250 mb-1"> 69 67 Data Storage 70 68 </div> 71 - <div className="font-medium text-purple-950 dark:text-cyan-50"> 69 + <Badge variant="status"> 72 70 {userSettings.saveData ? "✅ Enabled" : "❌ Disabled"} 73 - </div> 71 + </Badge> 74 72 </div> 75 73 <div> 76 74 <div className="text-purple-750 dark:text-cyan-250 mb-1"> 77 75 Automation 78 76 </div> 79 - <div className="font-medium text-purple-950 dark:text-cyan-50"> 77 + <Badge variant="status"> 80 78 {userSettings.enableAutomation 81 79 ? `✅ ${userSettings.automationFrequency}` 82 80 : "❌ Disabled"} 83 - </div> 81 + </Badge> 84 82 </div> 85 83 <div> 86 84 <div className="text-purple-750 dark:text-cyan-250 mb-1"> 87 85 Wizard 88 86 </div> 89 - <div className="font-medium text-purple-950 dark:text-cyan-50"> 87 + <Badge variant="status"> 90 88 {userSettings.wizardCompleted ? "✅ Completed" : "⏳ Pending"} 91 - </div> 89 + </Badge> 92 90 </div> 93 91 </div> 94 92 </div> 95 - </div> 93 + </Section> 96 94 97 95 {/* Match Destinations Section */} 98 - <div className="p-6 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 99 - <div className="flex items-center space-x-3 mb-4"> 100 - <div> 101 - <h2 className="text-xl font-bold text-purple-950 dark:text-cyan-50"> 102 - Match Destinations 103 - </h2> 104 - <p className="text-sm text-purple-750 dark:text-cyan-250"> 105 - Where matches should go for each platform 106 - </p> 107 - </div> 108 - </div> 109 - 110 - <div className="mt-3 px-3 py-2 rounded-lg border border-orange-650/50 dark:border-amber-400/50"> 96 + <Section 97 + title="Match Destinations" 98 + description="Where matches should go for each platform" 99 + divider 100 + > 101 + <Card className="mt-3 px-3 py-2 rounded-lg border-orange-650/50 dark:border-amber-400/50"> 111 102 <p className="text-sm text-purple-900 dark:text-cyan-100"> 112 103 💡 <strong>Tip:</strong> Choose different apps for different 113 104 platforms based on content type. For example, send TikTok matches to 114 105 Spark for video content. 115 106 </p> 116 - </div> 107 + </Card> 117 108 118 109 <div className="py-2 space-y-0"> 119 110 {Object.entries(PLATFORMS).map(([key, p]) => { 120 - const Icon = p.icon; 121 111 const currentDestination = 122 112 userSettings.platformDestinations[ 123 113 key as keyof PlatformDestinations ··· 128 118 key={key} 129 119 className="flex items-center justify-between px-3 py-2 rounded-xl transition-colors" 130 120 > 131 - <div className="flex items-center space-x-3 flex-1"> 132 - <Icon className="w-4 h-4 text-purple-950 dark:text-cyan-50 flex-shrink-0" /> 133 - <div className="flex-1 min-w-0"> 134 - <div className="font-medium text-purple-950 dark:text-cyan-50"> 135 - {p.name} 136 - </div> 137 - </div> 138 - </div> 121 + <PlatformBadge 122 + platformKey={key} 123 + size="sm" 124 + className="flex-1 min-w-0" 125 + /> 139 126 <select 140 127 value={currentDestination} 141 128 onChange={(e) => handleDestinationChange(key, e.target.value)} ··· 151 138 ); 152 139 })} 153 140 </div> 154 - </div> 141 + </Section> 155 142 156 143 {/* Privacy & Data Section */} 157 - <div className="p-6"> 158 - <div className="flex items-center space-x-3 mb-4"> 159 - <div> 160 - <h2 className="text-xl font-bold text-purple-950 dark:text-cyan-50"> 161 - Privacy & Data 162 - </h2> 163 - <p className="text-sm text-purple-750 dark:text-cyan-250"> 164 - Control how your data is stored 165 - </p> 166 - </div> 167 - </div> 168 - 144 + <Section 145 + title="Privacy & Data" 146 + description="Control how your data is stored" 147 + > 169 148 <div className="px-3 space-y-4"> 170 149 {/* Save Data Toggle */} 171 150 <div className=""> ··· 243 222 )} 244 223 </div> 245 224 </div> 246 - </div> 225 + </Section> 247 226 </div> 248 227 ); 249 228 }