@chesapeake/stack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +190 -0
  3. package/README.md +440 -0
  4. package/components/ui/avatar.tsx +87 -0
  5. package/components/ui/badge.tsx +49 -0
  6. package/components/ui/badgeVariants.ts +26 -0
  7. package/components/ui/button-group.tsx +67 -0
  8. package/components/ui/button.tsx +70 -0
  9. package/components/ui/buttonGroupVariants.ts +23 -0
  10. package/components/ui/buttonVariants.ts +38 -0
  11. package/components/ui/calendar.tsx +220 -0
  12. package/components/ui/card.tsx +162 -0
  13. package/components/ui/dialog.tsx +290 -0
  14. package/components/ui/drawer.tsx +227 -0
  15. package/components/ui/dropdown-menu.tsx +260 -0
  16. package/components/ui/index.ts +86 -0
  17. package/components/ui/input.tsx +59 -0
  18. package/components/ui/label.tsx +47 -0
  19. package/components/ui/location-input.md +194 -0
  20. package/components/ui/location-input.tsx +384 -0
  21. package/components/ui/popover.tsx +110 -0
  22. package/components/ui/select.tsx +306 -0
  23. package/components/ui/separator.tsx +50 -0
  24. package/components/ui/sonner-api.ts +8 -0
  25. package/components/ui/sonner.tsx +69 -0
  26. package/components/ui/textarea.tsx +53 -0
  27. package/components/ui/tooltip.tsx +125 -0
  28. package/components/ux/ActivityAnimation.tsx +135 -0
  29. package/components/ux/EmptyState.tsx +22 -0
  30. package/components/ux/FeedbackDrawer.tsx +141 -0
  31. package/components/ux/FireAnimation.tsx +189 -0
  32. package/components/ux/FriendList.tsx +111 -0
  33. package/components/ux/FriendRequestList.tsx +104 -0
  34. package/components/ux/GifSearch.tsx +264 -0
  35. package/components/ux/ImageUpload.tsx +829 -0
  36. package/components/ux/SelectedUsersList.tsx +91 -0
  37. package/components/ux/StandardLayout/PageLayout.tsx +107 -0
  38. package/components/ux/StandardLayout/ProfilePage.tsx +452 -0
  39. package/components/ux/StandardLayout/StandardLayout.tsx +345 -0
  40. package/components/ux/StandardLayout/index.ts +22 -0
  41. package/components/ux/TutorialCarousel.tsx +128 -0
  42. package/components/ux/UserSearch.tsx +324 -0
  43. package/components/ux/UserSelector.tsx +116 -0
  44. package/components/ux/auth/AuthSelector.tsx +154 -0
  45. package/components/ux/auth/MultiAuthProvider.tsx +196 -0
  46. package/components/ux/auth/README.md +317 -0
  47. package/components/ux/auth/context.ts +46 -0
  48. package/components/ux/auth/github/AuthCallback.tsx +177 -0
  49. package/components/ux/auth/github/AuthProvider.tsx +111 -0
  50. package/components/ux/auth/github/README.md +208 -0
  51. package/components/ux/auth/github/client.ts +127 -0
  52. package/components/ux/auth/github/config.ts +68 -0
  53. package/components/ux/auth/github/context.ts +34 -0
  54. package/components/ux/auth/github/default-client.ts +74 -0
  55. package/components/ux/auth/github/index.ts +43 -0
  56. package/components/ux/auth/github/types.ts +44 -0
  57. package/components/ux/auth/github/useGitHubAuth.ts +16 -0
  58. package/components/ux/auth/google/AuthCallback.tsx +170 -0
  59. package/components/ux/auth/google/AuthProvider.tsx +108 -0
  60. package/components/ux/auth/google/README.md +450 -0
  61. package/components/ux/auth/google/client.ts +109 -0
  62. package/components/ux/auth/google/config.ts +65 -0
  63. package/components/ux/auth/google/context.ts +34 -0
  64. package/components/ux/auth/google/default-client.ts +69 -0
  65. package/components/ux/auth/google/index.ts +43 -0
  66. package/components/ux/auth/google/types.ts +42 -0
  67. package/components/ux/auth/google/useGoogleAuth.ts +16 -0
  68. package/components/ux/auth/index.ts +72 -0
  69. package/components/ux/auth/useAuth.ts +16 -0
  70. package/components/ux/index.ts +46 -0
  71. package/components/ux/theme-provider-context.ts +20 -0
  72. package/components/ux/theme-provider.tsx +114 -0
  73. package/components/ux/useTheme.ts +42 -0
  74. package/index.ts +29 -0
  75. package/lib/utils.ts +57 -0
  76. package/package.json +170 -0
  77. package/styles/base.css +38 -0
  78. package/styles/components.css +54 -0
  79. package/styles/index.css +7 -0
  80. package/styles/tokens.css +189 -0
  81. package/tailwind.preset.ts +169 -0
  82. package/types/user.ts +69 -0
@@ -0,0 +1,135 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { cn } from "../../lib/utils";
5
+
6
+ interface ActivityAnimationProps {
7
+ /** Animation type: "pulse" for pulsing/spinner style, "particles" for particle motion */
8
+ type?: "pulse" | "particles";
9
+ /** Additional CSS classes to apply */
10
+ className?: string;
11
+ /** Size of the animation indicator */
12
+ size?: "sm" | "md" | "lg";
13
+ }
14
+
15
+ /**
16
+ * ActivityAnimation component displays animated activity indicators.
17
+ * Supports two animation types: pulse (spinner-style) and particles (motion-based).
18
+ * Useful for indicating active states, loading, or ongoing processes.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import { ActivityAnimation } from "@shared/components/ux/ActivityAnimation"
23
+ * import { Button } from "@shared/components/ui/button"
24
+ *
25
+ * // Pulse animation (default)
26
+ * function LoadingButton() {
27
+ * return (
28
+ * <Button disabled>
29
+ * <ActivityAnimation type="pulse" size="sm" className="mr-2" />
30
+ * Processing...
31
+ * </Button>
32
+ * )
33
+ * }
34
+ *
35
+ * // Particles animation
36
+ * function ActiveIndicator() {
37
+ * return (
38
+ * <div className="flex items-center gap-2">
39
+ * <ActivityAnimation type="particles" size="md" />
40
+ * <span>Active session</span>
41
+ * </div>
42
+ * )
43
+ * }
44
+ *
45
+ * // Inline with text
46
+ * function TimerButton({ isRunning }: { isRunning: boolean }) {
47
+ * return (
48
+ * <Button variant={isRunning ? "destructive" : "default"}>
49
+ * {isRunning && (
50
+ * <ActivityAnimation type="pulse" size="sm" className="mr-1" />
51
+ * )}
52
+ * {isRunning ? "25:00" : "Start Timer"}
53
+ * </Button>
54
+ * )
55
+ * }
56
+ * ```
57
+ */
58
+ export function ActivityAnimation({
59
+ type,
60
+ className,
61
+ size,
62
+ }: ActivityAnimationProps) {
63
+ const sizeClasses = {
64
+ sm: "w-3 h-3",
65
+ md: "w-4 h-4",
66
+ lg: "w-5 h-5",
67
+ };
68
+
69
+ if (type === "pulse") {
70
+ return (
71
+ <div
72
+ className={cn(
73
+ "relative inline-flex items-center justify-center",
74
+ className
75
+ )}
76
+ aria-label="Activity indicator"
77
+ >
78
+ <div
79
+ className={cn(
80
+ "rounded-full bg-current opacity-75",
81
+ sizeClasses[size ?? "md"],
82
+ "animate-pulse"
83
+ )}
84
+ />
85
+ <div
86
+ className={cn(
87
+ "absolute rounded-full bg-current opacity-40",
88
+ sizeClasses[size ?? "md"],
89
+ "animate-ping"
90
+ )}
91
+ />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ // Particles animation
97
+ return (
98
+ <div
99
+ className={cn(
100
+ "relative inline-flex items-center justify-center",
101
+ className
102
+ )}
103
+ aria-label="Activity indicator"
104
+ style={{
105
+ width: size === "sm" ? "12px" : size === "md" ? "16px" : "20px",
106
+ }}
107
+ >
108
+ {[0, 1, 2].map((i) => {
109
+ const offset = size === "sm" ? "-4px" : size === "md" ? "-6px" : "-8px";
110
+ const rotation = i * 120;
111
+ return (
112
+ <div
113
+ key={i}
114
+ className={cn(
115
+ "absolute rounded-full bg-current opacity-60",
116
+ size === "sm"
117
+ ? "w-1 h-1"
118
+ : size === "md"
119
+ ? "w-1.5 h-1.5"
120
+ : "w-2 h-2"
121
+ )}
122
+ style={{
123
+ animation: `particle-float-opacity 2s ease-in-out infinite`,
124
+ animationDelay: `${String(i * 0.3)}s`,
125
+ transform: `rotate(${String(rotation)}deg) translateY(${offset})`,
126
+ transformOrigin: "center",
127
+ }}
128
+ data-particle-index={i}
129
+ data-rotation={rotation}
130
+ />
131
+ );
132
+ })}
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,22 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ interface EmptyStateProps {
5
+ title?: string;
6
+ description?: string;
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * EmptyState component for displaying empty state messages
12
+ */
13
+ export function EmptyState({ title, description, className }: EmptyStateProps) {
14
+ return (
15
+ <div
16
+ className={`text-center py-12 px-4 text-muted-foreground ${className ?? ""}`}
17
+ >
18
+ <p className="mb-2">{title}</p>
19
+ {description && <p className="text-sm">{description}</p>}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,141 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import { useState } from "react";
6
+ import { MessageSquare, Send } from "lucide-react";
7
+ import { Button } from "../ui/button.tsx";
8
+ import { Textarea } from "../ui/textarea.tsx";
9
+ import { Label } from "../ui/label.tsx";
10
+ import {
11
+ Drawer,
12
+ DrawerClose,
13
+ DrawerContent,
14
+ DrawerDescription,
15
+ DrawerFooter,
16
+ DrawerHeader,
17
+ DrawerTitle,
18
+ } from "../ui/drawer.tsx";
19
+ import { toast } from "../ui/sonner-api.ts";
20
+
21
+ interface FeedbackDrawerProps {
22
+ isOpen: boolean;
23
+ onClose: () => void;
24
+ onSubmit: (feedback: string) => Promise<void>;
25
+ }
26
+
27
+ export function FeedbackDrawer({
28
+ isOpen,
29
+ onClose,
30
+ onSubmit,
31
+ }: FeedbackDrawerProps) {
32
+ const [feedback, setFeedback] = useState("");
33
+ const [isSubmitting, setIsSubmitting] = useState(false);
34
+
35
+ const handleSubmit = async (e: { preventDefault: () => void }) => {
36
+ e.preventDefault();
37
+ if (!feedback.trim() || feedback.trim().length < 10) return;
38
+
39
+ setIsSubmitting(true);
40
+ try {
41
+ await onSubmit(feedback.trim());
42
+ setFeedback("");
43
+ toast("Feedback Submitted", {
44
+ description: "Thank you for your feedback! We appreciate your input.",
45
+ });
46
+ onClose();
47
+ } catch (error) {
48
+ console.error("Failed to submit feedback:", error);
49
+ toast.error("Failed to Submit Feedback", {
50
+ description:
51
+ "There was an error submitting your feedback. Please try again.",
52
+ });
53
+ } finally {
54
+ setIsSubmitting(false);
55
+ }
56
+ };
57
+
58
+ const handleClose = () => {
59
+ if (!isSubmitting) {
60
+ setFeedback("");
61
+ onClose();
62
+ }
63
+ };
64
+
65
+ return (
66
+ <Drawer open={isOpen} onOpenChange={handleClose}>
67
+ <DrawerContent>
68
+ <div className="mx-auto w-full max-w-md">
69
+ <DrawerHeader>
70
+ <DrawerTitle className="flex items-center gap-2">
71
+ <MessageSquare className="h-5 w-5" />
72
+ Send Feedback
73
+ </DrawerTitle>
74
+ <DrawerDescription>
75
+ Help us improve by sharing your thoughts and suggestions
76
+ </DrawerDescription>
77
+ </DrawerHeader>
78
+
79
+ <form
80
+ onSubmit={(e) => {
81
+ e.preventDefault();
82
+ void handleSubmit(e);
83
+ }}
84
+ className="p-4 pb-0 space-y-4"
85
+ >
86
+ <div className="space-y-2">
87
+ <Label htmlFor="feedback">Your Feedback</Label>
88
+ <Textarea
89
+ id="feedback"
90
+ value={feedback}
91
+ onChange={(e) => {
92
+ setFeedback(e.target.value);
93
+ }}
94
+ placeholder="Tell us what you think, what features you'd like to see, or report any issues..."
95
+ rows={6}
96
+ className="resize-none"
97
+ disabled={isSubmitting}
98
+ maxLength={1000}
99
+ required
100
+ />
101
+ <p className="text-xs text-muted-foreground">
102
+ {feedback.length}/1000 characters (minimum 10 characters)
103
+ </p>
104
+ </div>
105
+ <DrawerFooter>
106
+ <div className="flex gap-2">
107
+ <DrawerClose asChild>
108
+ <Button
109
+ type="button"
110
+ variant="outline"
111
+ disabled={isSubmitting}
112
+ >
113
+ Cancel
114
+ </Button>
115
+ </DrawerClose>
116
+ <Button
117
+ type="submit"
118
+ disabled={
119
+ !feedback.trim() ||
120
+ feedback.trim().length < 10 ||
121
+ isSubmitting
122
+ }
123
+ className="flex-1"
124
+ >
125
+ {isSubmitting ? (
126
+ "Sending..."
127
+ ) : (
128
+ <>
129
+ <Send className="h-4 w-4 mr-2" />
130
+ Send Feedback
131
+ </>
132
+ )}
133
+ </Button>
134
+ </div>
135
+ </DrawerFooter>
136
+ </form>
137
+ </div>
138
+ </DrawerContent>
139
+ </Drawer>
140
+ );
141
+ }
@@ -0,0 +1,189 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { cn } from "../../lib/utils";
5
+
6
+ interface FireAnimationProps {
7
+ /** Productivity score used to determine fire intensity (0-3: ember, 4-7: small, 8-12: medium, 13+: large) */
8
+ score: number;
9
+ /** Additional CSS classes to apply */
10
+ className?: string;
11
+ }
12
+
13
+ type FireIntensity = "ember" | "small" | "medium" | "large";
14
+
15
+ /**
16
+ * FireAnimation component displays an animated fire visualization based on a productivity score.
17
+ * The fire intensity scales with the score, providing visual feedback for achievement levels.
18
+ * Uses SVG animations for smooth, performant rendering.
19
+ *
20
+ * Score tiers:
21
+ * - 0-3: Ember (minimal fire, 2 flames)
22
+ * - 4-7: Small Fire (moderate fire, 3 flames)
23
+ * - 8-12: Medium Fire (good fire, 4 flames)
24
+ * - 13+: Large Fire (impressive fire, 5 flames)
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * import { FireAnimation } from "@shared/components/ux/FireAnimation"
29
+ * import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@shared/components/ui/dialog"
30
+ *
31
+ * // In a completion modal
32
+ * function SessionCompleteModal({ score }: { score: number }) {
33
+ * return (
34
+ * <Dialog open={true}>
35
+ * <DialogContent>
36
+ * <DialogHeader>
37
+ * <DialogTitle>Session Complete!</DialogTitle>
38
+ * </DialogHeader>
39
+ * <div className="flex flex-col items-center py-4">
40
+ * <FireAnimation score={score} className="text-orange-500" />
41
+ * <p className="mt-2 text-sm">Your productivity score: {score}</p>
42
+ * </div>
43
+ * </DialogContent>
44
+ * </Dialog>
45
+ * )
46
+ * }
47
+ *
48
+ * // Calculate score and display
49
+ * function ProductivityDisplay({ tasksCompleted, tasksCreated, keystrokes }: Props) {
50
+ * const score = tasksCompleted * 2 + tasksCreated * 1 + Math.floor(keystrokes / 100)
51
+ *
52
+ * return (
53
+ * <div className="text-center">
54
+ * <FireAnimation score={score} className="text-red-500 dark:text-orange-400" />
55
+ * <p className="mt-2 text-lg font-bold">Score: {score}</p>
56
+ * </div>
57
+ * )
58
+ * }
59
+ *
60
+ * // With custom styling
61
+ * function CustomFireDisplay({ score }: { score: number }) {
62
+ * return (
63
+ * <div className="bg-gradient-to-b from-transparent to-orange-50 dark:to-orange-950 p-8 rounded-lg">
64
+ * <FireAnimation
65
+ * score={score}
66
+ * className="text-orange-600 dark:text-orange-400"
67
+ * />
68
+ * </div>
69
+ * )
70
+ * }
71
+ * ```
72
+ */
73
+ export function FireAnimation({ score, className }: FireAnimationProps) {
74
+ const getIntensity = (score: number): FireIntensity => {
75
+ if (score <= 3) return "ember";
76
+ if (score <= 7) return "small";
77
+ if (score <= 12) return "medium";
78
+ return "large";
79
+ };
80
+
81
+ const intensity = getIntensity(score);
82
+
83
+ const intensityConfig = {
84
+ ember: {
85
+ flames: 2,
86
+ height: "h-8",
87
+ opacity: "opacity-40",
88
+ scale: "scale-75",
89
+ },
90
+ small: {
91
+ flames: 3,
92
+ height: "h-12",
93
+ opacity: "opacity-60",
94
+ scale: "scale-90",
95
+ },
96
+ medium: {
97
+ flames: 4,
98
+ height: "h-16",
99
+ opacity: "opacity-80",
100
+ scale: "scale-100",
101
+ },
102
+ large: {
103
+ flames: 5,
104
+ height: "h-20",
105
+ opacity: "opacity-100",
106
+ scale: "scale-110",
107
+ },
108
+ };
109
+
110
+ const config = intensityConfig[intensity];
111
+
112
+ return (
113
+ <div
114
+ className={cn(
115
+ "relative inline-flex items-end justify-center",
116
+ config.height,
117
+ className
118
+ )}
119
+ aria-label={`Fire animation: ${intensity} intensity (score: ${String(score)})`}
120
+ >
121
+ <svg
122
+ viewBox="0 0 100 100"
123
+ className={cn("w-full h-full", config.scale)}
124
+ preserveAspectRatio="xMidYMid meet"
125
+ >
126
+ {Array.from({ length: config.flames }).map((_, i) => {
127
+ const delay = i * 0.15;
128
+ const offset = (i - (config.flames - 1) / 2) * 20;
129
+ const baseHeight = 30 + (i % 2) * 10;
130
+ const baseWidth = 15 + (i % 2) * 5;
131
+
132
+ return (
133
+ <g key={i} transform={`translate(${String(50 + offset)}, 80)`}>
134
+ <path
135
+ d={`M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
136
+ baseWidth / 2
137
+ )} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
138
+ baseHeight
139
+ )} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
140
+ baseWidth / 2
141
+ )} -${String(baseHeight * 0.7)} T 0 0`}
142
+ fill="currentColor"
143
+ className={cn(config.opacity, "animate-fire-flicker")}
144
+ style={{
145
+ animationDelay: `${String(delay)}s`,
146
+ transformOrigin: `${String(baseWidth / 2)}px ${String(baseHeight)}px`,
147
+ }}
148
+ >
149
+ <animate
150
+ attributeName="d"
151
+ values={`M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
152
+ baseWidth / 2
153
+ )} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
154
+ baseHeight
155
+ )} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
156
+ baseWidth / 2
157
+ )} -${String(baseHeight * 0.7)} T 0 0;
158
+ M 0 0 Q ${String(baseWidth / 3)} -${String(baseHeight * 0.35)} ${String(
159
+ baseWidth / 2
160
+ )} -${String(baseHeight * 0.65)} T ${String(baseWidth / 2)} -${String(
161
+ baseHeight
162
+ )} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.85)} ${String(
163
+ baseWidth / 2
164
+ )} -${String(baseHeight * 0.75)} T 0 0;
165
+ M 0 0 Q ${String(baseWidth / 2.5)} -${String(baseHeight * 0.32)} ${String(
166
+ baseWidth / 2
167
+ )} -${String(baseHeight * 0.58)} T ${String(baseWidth / 2)} -${String(
168
+ baseHeight
169
+ )} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.92)} ${String(
170
+ baseWidth / 2
171
+ )} -${String(baseHeight * 0.68)} T 0 0;
172
+ M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
173
+ baseWidth / 2
174
+ )} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
175
+ baseHeight
176
+ )} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
177
+ baseWidth / 2
178
+ )} -${String(baseHeight * 0.7)} T 0 0`}
179
+ dur="1.5s"
180
+ repeatCount="indefinite"
181
+ />
182
+ </path>
183
+ </g>
184
+ );
185
+ })}
186
+ </svg>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,111 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import { Button } from "../ui/button.tsx";
6
+ import { X } from "lucide-react";
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.tsx";
8
+ import type { BaseUser } from "../../types/user.ts";
9
+
10
+ export interface Friend extends BaseUser {
11
+ status: "pending" | "accepted";
12
+ presence?: number;
13
+ }
14
+
15
+ export interface FriendListProps {
16
+ friends: Friend[];
17
+ onRemove: (friendId: string) => void;
18
+ showPresence?: boolean;
19
+ emptyMessage?: string;
20
+ title?: string;
21
+ }
22
+
23
+ export function FriendList({
24
+ friends,
25
+ onRemove,
26
+ showPresence,
27
+ emptyMessage,
28
+ title,
29
+ }: FriendListProps) {
30
+ if (friends.length === 0) {
31
+ return (
32
+ <div className="space-y-2">
33
+ {title && <h3 className="font-medium">{title}</h3>}
34
+ <p className="text-sm text-muted-foreground">{emptyMessage}</p>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <div className="space-y-2">
41
+ {title && <h3 className="font-medium">{title}</h3>}
42
+ <div className="space-y-2 max-h-[400px] overflow-y-auto">
43
+ {friends.map((friend) => (
44
+ <div
45
+ key={friend.id}
46
+ className="flex items-center justify-between p-3 border rounded-lg"
47
+ >
48
+ <div className="flex items-center gap-3 flex-1 min-w-0">
49
+ {friend.profilePicture && (
50
+ <div className="relative flex-shrink-0">
51
+ <Tooltip>
52
+ <TooltipTrigger asChild>
53
+ <img
54
+ src={friend.profilePicture}
55
+ alt={friend.displayName}
56
+ className="w-10 h-10 rounded-full object-cover"
57
+ />
58
+ </TooltipTrigger>
59
+ <TooltipContent>
60
+ <p>{friend.displayName}</p>
61
+ {friend.email && (
62
+ <p className="text-xs text-muted-foreground">
63
+ {friend.email}
64
+ </p>
65
+ )}
66
+ {friend.status === "pending" && (
67
+ <p className="text-xs text-yellow-600">Pending</p>
68
+ )}
69
+ </TooltipContent>
70
+ </Tooltip>
71
+ {showPresence && friend.presence !== undefined && (
72
+ <div
73
+ className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-background ${
74
+ friend.presence > 0 ? "bg-green-500" : "bg-gray-400"
75
+ }`}
76
+ title={friend.presence > 0 ? "Online" : "Offline"}
77
+ />
78
+ )}
79
+ </div>
80
+ )}
81
+ <div className="flex-1 min-w-0">
82
+ <div className="font-medium truncate">{friend.displayName}</div>
83
+ {friend.email && (
84
+ <div className="text-sm text-muted-foreground truncate">
85
+ {friend.email}
86
+ </div>
87
+ )}
88
+ {friend.status === "pending" && (
89
+ <div className="text-xs text-yellow-600 mt-1">Pending</div>
90
+ )}
91
+ </div>
92
+ </div>
93
+ <div className="ml-4 flex-shrink-0">
94
+ <Button
95
+ type="button"
96
+ variant="destructive"
97
+ size="sm"
98
+ onClick={() => {
99
+ onRemove(friend.id);
100
+ }}
101
+ className="h-8 w-8 p-0"
102
+ >
103
+ <X className="h-4 w-4" />
104
+ </Button>
105
+ </div>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ );
111
+ }