@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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +190 -0
- package/README.md +440 -0
- package/components/ui/avatar.tsx +87 -0
- package/components/ui/badge.tsx +49 -0
- package/components/ui/badgeVariants.ts +26 -0
- package/components/ui/button-group.tsx +67 -0
- package/components/ui/button.tsx +70 -0
- package/components/ui/buttonGroupVariants.ts +23 -0
- package/components/ui/buttonVariants.ts +38 -0
- package/components/ui/calendar.tsx +220 -0
- package/components/ui/card.tsx +162 -0
- package/components/ui/dialog.tsx +290 -0
- package/components/ui/drawer.tsx +227 -0
- package/components/ui/dropdown-menu.tsx +260 -0
- package/components/ui/index.ts +86 -0
- package/components/ui/input.tsx +59 -0
- package/components/ui/label.tsx +47 -0
- package/components/ui/location-input.md +194 -0
- package/components/ui/location-input.tsx +384 -0
- package/components/ui/popover.tsx +110 -0
- package/components/ui/select.tsx +306 -0
- package/components/ui/separator.tsx +50 -0
- package/components/ui/sonner-api.ts +8 -0
- package/components/ui/sonner.tsx +69 -0
- package/components/ui/textarea.tsx +53 -0
- package/components/ui/tooltip.tsx +125 -0
- package/components/ux/ActivityAnimation.tsx +135 -0
- package/components/ux/EmptyState.tsx +22 -0
- package/components/ux/FeedbackDrawer.tsx +141 -0
- package/components/ux/FireAnimation.tsx +189 -0
- package/components/ux/FriendList.tsx +111 -0
- package/components/ux/FriendRequestList.tsx +104 -0
- package/components/ux/GifSearch.tsx +264 -0
- package/components/ux/ImageUpload.tsx +829 -0
- package/components/ux/SelectedUsersList.tsx +91 -0
- package/components/ux/StandardLayout/PageLayout.tsx +107 -0
- package/components/ux/StandardLayout/ProfilePage.tsx +452 -0
- package/components/ux/StandardLayout/StandardLayout.tsx +345 -0
- package/components/ux/StandardLayout/index.ts +22 -0
- package/components/ux/TutorialCarousel.tsx +128 -0
- package/components/ux/UserSearch.tsx +324 -0
- package/components/ux/UserSelector.tsx +116 -0
- package/components/ux/auth/AuthSelector.tsx +154 -0
- package/components/ux/auth/MultiAuthProvider.tsx +196 -0
- package/components/ux/auth/README.md +317 -0
- package/components/ux/auth/context.ts +46 -0
- package/components/ux/auth/github/AuthCallback.tsx +177 -0
- package/components/ux/auth/github/AuthProvider.tsx +111 -0
- package/components/ux/auth/github/README.md +208 -0
- package/components/ux/auth/github/client.ts +127 -0
- package/components/ux/auth/github/config.ts +68 -0
- package/components/ux/auth/github/context.ts +34 -0
- package/components/ux/auth/github/default-client.ts +74 -0
- package/components/ux/auth/github/index.ts +43 -0
- package/components/ux/auth/github/types.ts +44 -0
- package/components/ux/auth/github/useGitHubAuth.ts +16 -0
- package/components/ux/auth/google/AuthCallback.tsx +170 -0
- package/components/ux/auth/google/AuthProvider.tsx +108 -0
- package/components/ux/auth/google/README.md +450 -0
- package/components/ux/auth/google/client.ts +109 -0
- package/components/ux/auth/google/config.ts +65 -0
- package/components/ux/auth/google/context.ts +34 -0
- package/components/ux/auth/google/default-client.ts +69 -0
- package/components/ux/auth/google/index.ts +43 -0
- package/components/ux/auth/google/types.ts +42 -0
- package/components/ux/auth/google/useGoogleAuth.ts +16 -0
- package/components/ux/auth/index.ts +72 -0
- package/components/ux/auth/useAuth.ts +16 -0
- package/components/ux/index.ts +46 -0
- package/components/ux/theme-provider-context.ts +20 -0
- package/components/ux/theme-provider.tsx +114 -0
- package/components/ux/useTheme.ts +42 -0
- package/index.ts +29 -0
- package/lib/utils.ts +57 -0
- package/package.json +170 -0
- package/styles/base.css +38 -0
- package/styles/components.css +54 -0
- package/styles/index.css +7 -0
- package/styles/tokens.css +189 -0
- package/tailwind.preset.ts +169 -0
- package/types/user.ts +69 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { Input } from "../ui/input.tsx";
|
|
8
|
+
import { Button } from "../ui/button.tsx";
|
|
9
|
+
import { Label } from "../ui/label.tsx";
|
|
10
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.tsx";
|
|
11
|
+
import type { BaseUser } from "../../types/user.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Props for the UserSearch component
|
|
15
|
+
*/
|
|
16
|
+
export interface UserSearchProps {
|
|
17
|
+
/** Callback when a user is selected from search results */
|
|
18
|
+
onUserSelect: (user: BaseUser) => void;
|
|
19
|
+
/** Array of user IDs to exclude from search results */
|
|
20
|
+
excludeUserIds?: string[];
|
|
21
|
+
/** Current user's ID (excluded from results) */
|
|
22
|
+
currentUserId?: string;
|
|
23
|
+
/** API endpoint for searching users. Should accept a 'q' query parameter */
|
|
24
|
+
searchEndpoint?: string;
|
|
25
|
+
/** Placeholder text for the search input */
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/** Minimum characters required before searching. @default 2 */
|
|
28
|
+
minQueryLength?: number;
|
|
29
|
+
/** Debounce delay in milliseconds. @default 300 */
|
|
30
|
+
debounceMs?: number;
|
|
31
|
+
/** Label displayed above the search input */
|
|
32
|
+
label?: string;
|
|
33
|
+
/** Whether to show an "Add" button next to results */
|
|
34
|
+
showAddButton?: boolean;
|
|
35
|
+
/** Function to check if a user is already selected */
|
|
36
|
+
isSelected?: (userId: string) => boolean;
|
|
37
|
+
/** Label displayed for selected users. @default "Selected" */
|
|
38
|
+
selectedLabel?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A searchable input component for finding and selecting users.
|
|
43
|
+
*
|
|
44
|
+
* This component provides a search interface with debounced API calls,
|
|
45
|
+
* displaying results in a dropdown. Supports excluding users, marking
|
|
46
|
+
* selected users, and customizable appearance.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* // Basic usage
|
|
51
|
+
* <UserSearch
|
|
52
|
+
* onUserSelect={(user) => console.log("Selected:", user)}
|
|
53
|
+
* searchEndpoint="/api/users/search"
|
|
54
|
+
* placeholder="Search users..."
|
|
55
|
+
* />
|
|
56
|
+
*
|
|
57
|
+
* // With exclusions and selected state
|
|
58
|
+
* <UserSearch
|
|
59
|
+
* onUserSelect={handleSelect}
|
|
60
|
+
* searchEndpoint="/api/users/search"
|
|
61
|
+
* currentUserId="123"
|
|
62
|
+
* excludeUserIds={["456", "789"]}
|
|
63
|
+
* isSelected={(id) => selectedIds.includes(id)}
|
|
64
|
+
* showAddButton
|
|
65
|
+
* />
|
|
66
|
+
*
|
|
67
|
+
* // In a form with label
|
|
68
|
+
* <UserSearch
|
|
69
|
+
* label="Add Team Member"
|
|
70
|
+
* onUserSelect={addMember}
|
|
71
|
+
* searchEndpoint="/api/users/search"
|
|
72
|
+
* minQueryLength={3}
|
|
73
|
+
* debounceMs={500}
|
|
74
|
+
* />
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @sideeffect
|
|
78
|
+
* - Makes fetch requests to searchEndpoint
|
|
79
|
+
* - Adds/removes document event listeners for click-outside handling
|
|
80
|
+
* - Uses setTimeout for debouncing
|
|
81
|
+
*/
|
|
82
|
+
export function UserSearch({
|
|
83
|
+
onUserSelect,
|
|
84
|
+
excludeUserIds,
|
|
85
|
+
currentUserId,
|
|
86
|
+
searchEndpoint,
|
|
87
|
+
placeholder,
|
|
88
|
+
minQueryLength,
|
|
89
|
+
debounceMs,
|
|
90
|
+
label,
|
|
91
|
+
showAddButton,
|
|
92
|
+
isSelected,
|
|
93
|
+
selectedLabel,
|
|
94
|
+
}: UserSearchProps) {
|
|
95
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
96
|
+
const [searchResults, setSearchResults] = useState<BaseUser[]>([]);
|
|
97
|
+
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
98
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
99
|
+
const searchRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
101
|
+
undefined
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const performSearch = useCallback(
|
|
105
|
+
async (query: string) => {
|
|
106
|
+
const minLen = minQueryLength ?? 2;
|
|
107
|
+
if (query.length < minLen) {
|
|
108
|
+
setSearchResults([]);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const endpoint = searchEndpoint;
|
|
113
|
+
if (!endpoint) {
|
|
114
|
+
setSearchResults([]);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setIsLoading(true);
|
|
119
|
+
try {
|
|
120
|
+
const url = new URL(endpoint, globalThis.location.origin);
|
|
121
|
+
url.searchParams.set("q", query);
|
|
122
|
+
|
|
123
|
+
const headers: HeadersInit = {};
|
|
124
|
+
if (currentUserId) {
|
|
125
|
+
headers["X-User-ID"] = currentUserId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await fetch(url.toString(), {
|
|
129
|
+
headers,
|
|
130
|
+
credentials: "include", // Include cookies for authentication
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error("Failed to search users");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const data = (await response.json()) as { value?: BaseUser[] };
|
|
137
|
+
const users = data.value ?? [];
|
|
138
|
+
|
|
139
|
+
// Filter out excluded users
|
|
140
|
+
const excludeIds = excludeUserIds ?? [];
|
|
141
|
+
const filtered = users.filter(
|
|
142
|
+
(u) => u.id !== currentUserId && !excludeIds.includes(u.id)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
setSearchResults(filtered);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error("Error searching users:", error);
|
|
148
|
+
setSearchResults([]);
|
|
149
|
+
} finally {
|
|
150
|
+
setIsLoading(false);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
[searchEndpoint, currentUserId, excludeUserIds, minQueryLength]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleSearch = useCallback(
|
|
157
|
+
(query: string) => {
|
|
158
|
+
setSearchQuery(query);
|
|
159
|
+
setIsSearchOpen(true);
|
|
160
|
+
|
|
161
|
+
// Clear previous timeout
|
|
162
|
+
if (debounceTimeoutRef.current) {
|
|
163
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Set new timeout for debounced search
|
|
167
|
+
debounceTimeoutRef.current = setTimeout(() => {
|
|
168
|
+
void performSearch(query);
|
|
169
|
+
}, debounceMs);
|
|
170
|
+
},
|
|
171
|
+
[performSearch, debounceMs]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleUserClick = useCallback(
|
|
175
|
+
(user: BaseUser) => {
|
|
176
|
+
onUserSelect(user);
|
|
177
|
+
setSearchQuery("");
|
|
178
|
+
setSearchResults([]);
|
|
179
|
+
setIsSearchOpen(false);
|
|
180
|
+
},
|
|
181
|
+
[onUserSelect]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Close search when clicking outside
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
187
|
+
if (
|
|
188
|
+
searchRef.current &&
|
|
189
|
+
!searchRef.current.contains(event.target as Node)
|
|
190
|
+
) {
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
setSearchQuery("");
|
|
193
|
+
setSearchResults([]);
|
|
194
|
+
setIsSearchOpen(false);
|
|
195
|
+
}, 200);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
200
|
+
return () => {
|
|
201
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
202
|
+
if (debounceTimeoutRef.current) {
|
|
203
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
const userIsSelected = (userId: string) => {
|
|
209
|
+
return isSelected ? isSelected(userId) : false;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="relative" ref={searchRef}>
|
|
214
|
+
{label && <Label htmlFor="user-search">{label}</Label>}
|
|
215
|
+
<div className="relative">
|
|
216
|
+
<Input
|
|
217
|
+
id="user-search"
|
|
218
|
+
value={searchQuery}
|
|
219
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
220
|
+
handleSearch(e.currentTarget.value);
|
|
221
|
+
}}
|
|
222
|
+
placeholder={placeholder}
|
|
223
|
+
autoComplete="off"
|
|
224
|
+
onFocus={() => {
|
|
225
|
+
setIsSearchOpen(true);
|
|
226
|
+
const minLen = minQueryLength ?? 2;
|
|
227
|
+
if (searchQuery.length >= minLen) {
|
|
228
|
+
void performSearch(searchQuery);
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
{isSearchOpen && (
|
|
233
|
+
<Button
|
|
234
|
+
type="button"
|
|
235
|
+
variant="ghost"
|
|
236
|
+
size="sm"
|
|
237
|
+
className="absolute right-2 top-1/2 -translate-y-1/2"
|
|
238
|
+
onClick={() => {
|
|
239
|
+
setSearchQuery("");
|
|
240
|
+
setSearchResults([]);
|
|
241
|
+
setIsSearchOpen(false);
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
✕
|
|
245
|
+
</Button>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{isSearchOpen && (
|
|
250
|
+
<div className="absolute z-10 w-full mt-1 bg-background border rounded-lg shadow-lg max-h-96 overflow-y-auto">
|
|
251
|
+
{isLoading ? (
|
|
252
|
+
<div className="p-4 text-center text-muted-foreground">
|
|
253
|
+
Searching...
|
|
254
|
+
</div>
|
|
255
|
+
) : searchResults.length > 0 ? (
|
|
256
|
+
searchResults.map((user) => (
|
|
257
|
+
<div
|
|
258
|
+
key={user.id}
|
|
259
|
+
className="flex items-center justify-between p-2 border-b last:border-b-0 hover:bg-muted/50 cursor-pointer"
|
|
260
|
+
onClick={() => {
|
|
261
|
+
handleUserClick(user);
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
265
|
+
{user.profilePicture && (
|
|
266
|
+
<Tooltip>
|
|
267
|
+
<TooltipTrigger asChild>
|
|
268
|
+
<img
|
|
269
|
+
src={user.profilePicture}
|
|
270
|
+
alt={user.displayName}
|
|
271
|
+
className="w-8 h-8 rounded-full object-cover"
|
|
272
|
+
/>
|
|
273
|
+
</TooltipTrigger>
|
|
274
|
+
<TooltipContent>
|
|
275
|
+
<p>{user.displayName}</p>
|
|
276
|
+
{user.email && (
|
|
277
|
+
<p className="text-xs text-muted-foreground">
|
|
278
|
+
{user.email}
|
|
279
|
+
</p>
|
|
280
|
+
)}
|
|
281
|
+
</TooltipContent>
|
|
282
|
+
</Tooltip>
|
|
283
|
+
)}
|
|
284
|
+
<div className="flex-1 min-w-0">
|
|
285
|
+
<div className="font-medium truncate">
|
|
286
|
+
{user.displayName}
|
|
287
|
+
</div>
|
|
288
|
+
{user.email && (
|
|
289
|
+
<div className="text-sm text-muted-foreground truncate">
|
|
290
|
+
{user.email}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
{userIsSelected(user.id) ? (
|
|
296
|
+
<div className="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full flex-shrink-0">
|
|
297
|
+
{selectedLabel}
|
|
298
|
+
</div>
|
|
299
|
+
) : showAddButton ? (
|
|
300
|
+
<Button
|
|
301
|
+
type="button"
|
|
302
|
+
variant="default"
|
|
303
|
+
size="sm"
|
|
304
|
+
onClick={(e) => {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
handleUserClick(user);
|
|
307
|
+
}}
|
|
308
|
+
className="flex-shrink-0"
|
|
309
|
+
>
|
|
310
|
+
Add
|
|
311
|
+
</Button>
|
|
312
|
+
) : null}
|
|
313
|
+
</div>
|
|
314
|
+
))
|
|
315
|
+
) : searchQuery.length >= (minQueryLength ?? 2) ? (
|
|
316
|
+
<div className="p-4 text-center text-muted-foreground">
|
|
317
|
+
No users found
|
|
318
|
+
</div>
|
|
319
|
+
) : null}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import { useCallback, useState } from "react";
|
|
6
|
+
import { UserSearch } from "./UserSearch.tsx";
|
|
7
|
+
import { type SelectedUser, SelectedUsersList } from "./SelectedUsersList.tsx";
|
|
8
|
+
import type { BaseUser } from "../../types/user.ts";
|
|
9
|
+
|
|
10
|
+
export interface UserSelectorProps {
|
|
11
|
+
selectedUsers: BaseUser[];
|
|
12
|
+
onUsersChange: (users: BaseUser[]) => void;
|
|
13
|
+
excludeUserIds?: string[];
|
|
14
|
+
currentUserId?: string;
|
|
15
|
+
searchEndpoint?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
minQueryLength?: number;
|
|
18
|
+
debounceMs?: number;
|
|
19
|
+
showStatus?: boolean;
|
|
20
|
+
maxHeight?: string;
|
|
21
|
+
emptyMessage?: string;
|
|
22
|
+
searchLabel?: string;
|
|
23
|
+
listTitle?: string;
|
|
24
|
+
selectedLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function UserSelector({
|
|
28
|
+
selectedUsers,
|
|
29
|
+
onUsersChange,
|
|
30
|
+
excludeUserIds,
|
|
31
|
+
currentUserId,
|
|
32
|
+
searchEndpoint,
|
|
33
|
+
placeholder,
|
|
34
|
+
minQueryLength,
|
|
35
|
+
debounceMs,
|
|
36
|
+
showStatus,
|
|
37
|
+
maxHeight,
|
|
38
|
+
emptyMessage,
|
|
39
|
+
searchLabel,
|
|
40
|
+
listTitle,
|
|
41
|
+
selectedLabel,
|
|
42
|
+
}: UserSelectorProps) {
|
|
43
|
+
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
|
44
|
+
new Set(selectedUsers.map((u) => u.id))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const handleUserSelect = useCallback(
|
|
48
|
+
(user: BaseUser) => {
|
|
49
|
+
if (selectedUserIds.has(user.id)) {
|
|
50
|
+
return; // Already selected
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const newUsers = [...selectedUsers, user];
|
|
54
|
+
const newIds = new Set(selectedUserIds);
|
|
55
|
+
newIds.add(user.id);
|
|
56
|
+
setSelectedUserIds(newIds);
|
|
57
|
+
onUsersChange(newUsers);
|
|
58
|
+
},
|
|
59
|
+
[selectedUsers, selectedUserIds, onUsersChange]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const handleRemove = useCallback(
|
|
63
|
+
(userId: string) => {
|
|
64
|
+
const newUsers = selectedUsers.filter((u) => u.id !== userId);
|
|
65
|
+
const newIds = new Set(selectedUserIds);
|
|
66
|
+
newIds.delete(userId);
|
|
67
|
+
setSelectedUserIds(newIds);
|
|
68
|
+
onUsersChange(newUsers);
|
|
69
|
+
},
|
|
70
|
+
[selectedUsers, selectedUserIds, onUsersChange]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const isSelected = useCallback(
|
|
74
|
+
(userId: string) => selectedUserIds.has(userId),
|
|
75
|
+
[selectedUserIds]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Convert BaseUser to SelectedUser format
|
|
79
|
+
const selectedUsersList: SelectedUser[] = selectedUsers.map((u) => ({
|
|
80
|
+
userId: u.id,
|
|
81
|
+
displayName: u.displayName,
|
|
82
|
+
email: u.email,
|
|
83
|
+
profilePicture: u.profilePicture,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// Combine excludeUserIds with selected user IDs
|
|
87
|
+
const allExcludedIds = [
|
|
88
|
+
...(excludeUserIds ?? []),
|
|
89
|
+
...Array.from(selectedUserIds),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-4">
|
|
94
|
+
<UserSearch
|
|
95
|
+
onUserSelect={handleUserSelect}
|
|
96
|
+
excludeUserIds={allExcludedIds}
|
|
97
|
+
currentUserId={currentUserId}
|
|
98
|
+
searchEndpoint={searchEndpoint}
|
|
99
|
+
placeholder={placeholder}
|
|
100
|
+
minQueryLength={minQueryLength}
|
|
101
|
+
debounceMs={debounceMs}
|
|
102
|
+
label={searchLabel}
|
|
103
|
+
isSelected={isSelected}
|
|
104
|
+
selectedLabel={selectedLabel}
|
|
105
|
+
/>
|
|
106
|
+
<SelectedUsersList
|
|
107
|
+
users={selectedUsersList}
|
|
108
|
+
onRemove={handleRemove}
|
|
109
|
+
showStatus={showStatus}
|
|
110
|
+
maxHeight={maxHeight}
|
|
111
|
+
emptyMessage={emptyMessage}
|
|
112
|
+
title={listTitle}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Button } from "../../ui/button";
|
|
7
|
+
import { useAuth } from "./useAuth.ts";
|
|
8
|
+
import type { AuthUser } from "./context.ts";
|
|
9
|
+
|
|
10
|
+
export interface AuthSelectorProps {
|
|
11
|
+
/** Enable Google OAuth provider */
|
|
12
|
+
enableGoogle?: boolean;
|
|
13
|
+
/** Enable GitHub OAuth provider */
|
|
14
|
+
enableGithub?: boolean;
|
|
15
|
+
/** Custom styling/className */
|
|
16
|
+
className?: string;
|
|
17
|
+
/** Callback when user successfully authenticates */
|
|
18
|
+
onAuthSuccess?: (user: AuthUser, provider: "google" | "github") => void;
|
|
19
|
+
/** Custom button components (optional) */
|
|
20
|
+
googleButton?: React.ReactNode;
|
|
21
|
+
githubButton?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* AuthSelector component for choosing authentication provider
|
|
26
|
+
* Renders buttons for enabled providers and disables them when authenticated
|
|
27
|
+
*/
|
|
28
|
+
export function AuthSelector({
|
|
29
|
+
enableGoogle,
|
|
30
|
+
enableGithub,
|
|
31
|
+
className,
|
|
32
|
+
onAuthSuccess,
|
|
33
|
+
googleButton,
|
|
34
|
+
githubButton,
|
|
35
|
+
}: AuthSelectorProps) {
|
|
36
|
+
const {
|
|
37
|
+
user,
|
|
38
|
+
loading,
|
|
39
|
+
provider,
|
|
40
|
+
signInWithGoogle,
|
|
41
|
+
signInWithGitHub,
|
|
42
|
+
signOut,
|
|
43
|
+
} = useAuth();
|
|
44
|
+
|
|
45
|
+
// Validate that user is actually a valid user object, not just truthy
|
|
46
|
+
const isAuthenticated = !!(
|
|
47
|
+
user &&
|
|
48
|
+
typeof user === "object" &&
|
|
49
|
+
Object.keys(user).length > 0
|
|
50
|
+
);
|
|
51
|
+
const hasMultipleProviders = enableGoogle && enableGithub;
|
|
52
|
+
|
|
53
|
+
// Call onAuthSuccess when user becomes authenticated
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (user && provider && onAuthSuccess) {
|
|
56
|
+
onAuthSuccess(user, provider);
|
|
57
|
+
}
|
|
58
|
+
}, [user, provider, onAuthSuccess]);
|
|
59
|
+
|
|
60
|
+
if (loading) {
|
|
61
|
+
return (
|
|
62
|
+
<div className={`flex items-center justify-center ${className ?? ""}`}>
|
|
63
|
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isAuthenticated) {
|
|
69
|
+
return (
|
|
70
|
+
<div className={`flex flex-col gap-2 ${className ?? ""}`}>
|
|
71
|
+
<div className="text-sm text-muted-foreground">
|
|
72
|
+
Signed in with {provider === "google" ? "Google" : "GitHub"}
|
|
73
|
+
</div>
|
|
74
|
+
<Button onClick={signOut} variant="outline" size="sm">
|
|
75
|
+
Sign Out
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={`flex flex-col gap-2 ${className ?? ""}`}>
|
|
83
|
+
{hasMultipleProviders && (
|
|
84
|
+
<p className="text-sm text-muted-foreground mb-2">
|
|
85
|
+
Choose an authentication provider:
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
{enableGoogle &&
|
|
90
|
+
(googleButton ?? (
|
|
91
|
+
<Button
|
|
92
|
+
onClick={signInWithGoogle}
|
|
93
|
+
disabled={isAuthenticated}
|
|
94
|
+
variant="outline"
|
|
95
|
+
className="w-full"
|
|
96
|
+
>
|
|
97
|
+
<svg
|
|
98
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
99
|
+
width="20"
|
|
100
|
+
height="20"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
fill="currentColor"
|
|
103
|
+
className="mr-2"
|
|
104
|
+
>
|
|
105
|
+
<path
|
|
106
|
+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
107
|
+
fill="#4285F4"
|
|
108
|
+
/>
|
|
109
|
+
<path
|
|
110
|
+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
111
|
+
fill="#34A853"
|
|
112
|
+
/>
|
|
113
|
+
<path
|
|
114
|
+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
115
|
+
fill="#FBBC05"
|
|
116
|
+
/>
|
|
117
|
+
<path
|
|
118
|
+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
119
|
+
fill="#EA4335"
|
|
120
|
+
/>
|
|
121
|
+
</svg>
|
|
122
|
+
Sign in with Google
|
|
123
|
+
</Button>
|
|
124
|
+
))}
|
|
125
|
+
{enableGithub &&
|
|
126
|
+
(githubButton ?? (
|
|
127
|
+
<Button
|
|
128
|
+
onClick={signInWithGitHub}
|
|
129
|
+
disabled={isAuthenticated}
|
|
130
|
+
variant="outline"
|
|
131
|
+
className="w-full"
|
|
132
|
+
>
|
|
133
|
+
<svg
|
|
134
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
135
|
+
width="20"
|
|
136
|
+
height="20"
|
|
137
|
+
viewBox="0 0 24 24"
|
|
138
|
+
fill="none"
|
|
139
|
+
stroke="currentColor"
|
|
140
|
+
strokeWidth="2"
|
|
141
|
+
strokeLinecap="round"
|
|
142
|
+
strokeLinejoin="round"
|
|
143
|
+
className="mr-2"
|
|
144
|
+
>
|
|
145
|
+
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
|
146
|
+
<path d="M9 18c-4.51 2-5-2-7-2" />
|
|
147
|
+
</svg>
|
|
148
|
+
Sign in with GitHub
|
|
149
|
+
</Button>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|