+24
-38
src/components/HistoryTab.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}