@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,104 @@
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 { Check, X } from "lucide-react";
7
+ import type { BaseUser } from "../../types/user.ts";
8
+
9
+ export interface FriendRequest {
10
+ id: string;
11
+ requester: BaseUser | null;
12
+ status: "pending" | "accepted" | "rejected";
13
+ createdAt: string;
14
+ }
15
+
16
+ export interface FriendRequestListProps {
17
+ requests: FriendRequest[];
18
+ onAccept: (requestId: string) => void;
19
+ onReject: (requestId: string) => void;
20
+ emptyMessage?: string;
21
+ title?: string;
22
+ }
23
+
24
+ export function FriendRequestList({
25
+ requests,
26
+ onAccept,
27
+ onReject,
28
+ emptyMessage,
29
+ title,
30
+ }: FriendRequestListProps) {
31
+ const pendingRequests = requests.filter((r) => r.status === "pending");
32
+
33
+ if (pendingRequests.length === 0) {
34
+ return (
35
+ <div className="space-y-2">
36
+ {title && <h3 className="font-medium">{title}</h3>}
37
+ <p className="text-sm text-muted-foreground">{emptyMessage}</p>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div className="space-y-2">
44
+ {title && <h3 className="font-medium">{title}</h3>}
45
+ <div className="space-y-2 max-h-[400px] overflow-y-auto">
46
+ {pendingRequests.map((request) => (
47
+ <div
48
+ key={request.id}
49
+ className="flex items-center justify-between p-3 border rounded-lg"
50
+ >
51
+ <div className="flex items-center gap-3 flex-1 min-w-0">
52
+ {request.requester?.profilePicture && (
53
+ <img
54
+ src={request.requester.profilePicture}
55
+ alt={request.requester.displayName}
56
+ className="w-10 h-10 rounded-full object-cover flex-shrink-0"
57
+ />
58
+ )}
59
+ <div className="flex-1 min-w-0">
60
+ <div className="font-medium truncate">
61
+ {request.requester?.displayName ?? "Unknown User"}
62
+ </div>
63
+ {request.requester?.email && (
64
+ <div className="text-sm text-muted-foreground truncate">
65
+ {request.requester.email}
66
+ </div>
67
+ )}
68
+ <div className="text-xs text-muted-foreground mt-1">
69
+ {new Date(request.createdAt).toLocaleDateString()}
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <div className="ml-4 flex gap-2 flex-shrink-0">
74
+ <Button
75
+ type="button"
76
+ variant="default"
77
+ size="sm"
78
+ onClick={() => {
79
+ onAccept(request.id);
80
+ }}
81
+ className="h-8"
82
+ >
83
+ <Check className="h-4 w-4 mr-1" />
84
+ Accept
85
+ </Button>
86
+ <Button
87
+ type="button"
88
+ variant="destructive"
89
+ size="sm"
90
+ onClick={() => {
91
+ onReject(request.id);
92
+ }}
93
+ className="h-8"
94
+ >
95
+ <X className="h-4 w-4 mr-1" />
96
+ Reject
97
+ </Button>
98
+ </div>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,264 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+
6
+ import { Search } from "lucide-react";
7
+ import React, { useState } from "react";
8
+ import { Button } from "../ui/button.tsx";
9
+ import {
10
+ Drawer,
11
+ DrawerClose,
12
+ DrawerContent,
13
+ DrawerDescription,
14
+ DrawerFooter,
15
+ DrawerHeader,
16
+ DrawerTitle,
17
+ } from "../ui/drawer.tsx";
18
+ import { Input } from "../ui/input.tsx";
19
+
20
+ /**
21
+ * Media format information from Tenor API
22
+ */
23
+ interface TenorMediaFormat {
24
+ /** URL of the media file */
25
+ url: string;
26
+ /** Dimensions of the media [width, height] */
27
+ dims: number[];
28
+ /** File size in bytes */
29
+ size: number;
30
+ }
31
+
32
+ /**
33
+ * Individual GIF result from Tenor API
34
+ */
35
+ interface TenorResult {
36
+ /** Unique identifier for the GIF */
37
+ id: string;
38
+ /** Title or description of the GIF */
39
+ title: string;
40
+ /** Available media formats for this GIF */
41
+ media_formats: {
42
+ /** Standard GIF format */
43
+ gif?: TenorMediaFormat;
44
+ /** Small/optimized GIF for previews */
45
+ tinygif?: TenorMediaFormat;
46
+ /** MP4 video format */
47
+ mp4?: TenorMediaFormat;
48
+ /** Small MP4 for previews */
49
+ tinymp4?: TenorMediaFormat;
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Response structure from Tenor search API
55
+ */
56
+ interface TenorSearchResponse {
57
+ /** Array of search results */
58
+ results: TenorResult[];
59
+ /** Token for fetching next page of results */
60
+ next: string;
61
+ }
62
+
63
+ /**
64
+ * Props for the GifSearch component
65
+ */
66
+ interface GifSearchProps {
67
+ /** Controls whether the search drawer is open */
68
+ isOpen: boolean;
69
+ /** Callback when the drawer should be closed */
70
+ onClose: () => void;
71
+ /** Callback when a GIF is selected, receives the GIF URL */
72
+ onSelectGif: (gifUrl: string) => void;
73
+ /**
74
+ * Tenor API key for searching GIFs.
75
+ * Obtain from https://developers.google.com/tenor/guides/quickstart
76
+ */
77
+ apiKey: string;
78
+ /**
79
+ * Client key for Tenor API analytics.
80
+ * Used to track which application is making requests.
81
+ * Defaults to your app's name or identifier.
82
+ */
83
+ clientKey?: string;
84
+ }
85
+
86
+ /**
87
+ * A drawer component for searching and selecting GIFs using the Tenor API.
88
+ *
89
+ * This component provides a UI for searching GIFs and selecting one from the results.
90
+ * It requires a Tenor API key which can be obtained from Google Cloud Console.
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * function MyComponent() {
95
+ * const [isOpen, setIsOpen] = useState(false);
96
+ * const [selectedGif, setSelectedGif] = useState<string | null>(null);
97
+ *
98
+ * return (
99
+ * <>
100
+ * <Button onClick={() => setIsOpen(true)}>Search GIFs</Button>
101
+ * <GifSearch
102
+ * isOpen={isOpen}
103
+ * onClose={() => setIsOpen(false)}
104
+ * onSelectGif={(url) => {
105
+ * setSelectedGif(url);
106
+ * setIsOpen(false);
107
+ * }}
108
+ * apiKey={process.env.VITE_TENOR_API_KEY}
109
+ * />
110
+ * </>
111
+ * );
112
+ * }
113
+ * ```
114
+ */
115
+ export function GifSearch({
116
+ isOpen,
117
+ onClose,
118
+ onSelectGif,
119
+ apiKey,
120
+ clientKey = "stack",
121
+ }: GifSearchProps) {
122
+ const [searchQuery, setSearchQuery] = useState("");
123
+ const [isLoading, setIsLoading] = useState(false);
124
+ const [gifResults, setGifResults] = useState<string[]>([]);
125
+
126
+ const handleSearch = async (e: React.SyntheticEvent<HTMLFormElement>) => {
127
+ e.preventDefault();
128
+ if (!searchQuery.trim()) return;
129
+
130
+ setIsLoading(true);
131
+
132
+ try {
133
+ const params = new URLSearchParams({
134
+ q: searchQuery,
135
+ key: apiKey,
136
+ client_key: clientKey,
137
+ limit: "8",
138
+ media_filter: "gif,tinygif,mp4,tinymp4",
139
+ contentfilter: "medium",
140
+ });
141
+
142
+ const response = await fetch(
143
+ `https://tenor.googleapis.com/v2/search?${params}`
144
+ );
145
+
146
+ if (!response.ok) {
147
+ throw new Error(`Tenor API error: ${String(response.status)}`);
148
+ }
149
+
150
+ const data = (await response.json()) as TenorSearchResponse;
151
+
152
+ if (Array.isArray(data.results)) {
153
+ const gifUrls = data.results
154
+ .map((result: TenorResult) => {
155
+ // Prefer tinygif for preview, fallback to gif
156
+ const mediaFormats = result.media_formats;
157
+ return (
158
+ mediaFormats.tinygif?.url ??
159
+ mediaFormats.gif?.url ??
160
+ mediaFormats.mp4?.url
161
+ );
162
+ })
163
+ .filter((url): url is string => url !== undefined);
164
+
165
+ setGifResults(gifUrls);
166
+ } else {
167
+ setGifResults([]);
168
+ }
169
+ } catch (error) {
170
+ console.error("Error searching GIFs:", error);
171
+ setGifResults([]);
172
+ } finally {
173
+ setIsLoading(false);
174
+ }
175
+ };
176
+
177
+ const handleGifSelect = (gifUrl: string) => {
178
+ onSelectGif(gifUrl);
179
+ onClose();
180
+ };
181
+
182
+ return (
183
+ <Drawer open={isOpen} onOpenChange={onClose}>
184
+ <DrawerContent>
185
+ <div className="mx-auto w-full max-w-md">
186
+ <DrawerHeader>
187
+ <DrawerTitle>Search GIFs</DrawerTitle>
188
+ <DrawerDescription>
189
+ Find the perfect GIF to express yourself in the chat.
190
+ </DrawerDescription>
191
+ </DrawerHeader>
192
+
193
+ <div className="p-4 pb-0">
194
+ {/* Search Form */}
195
+ <form
196
+ onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
197
+ void handleSearch(e);
198
+ }}
199
+ className="flex gap-2 mb-4"
200
+ >
201
+ <Input
202
+ value={searchQuery}
203
+ onChange={(e) => {
204
+ const value = (e.target as unknown as { value: string })
205
+ .value;
206
+ setSearchQuery(value);
207
+ }}
208
+ placeholder="Search for GIFs..."
209
+ className="flex-1"
210
+ />
211
+ <Button type="submit" disabled={isLoading}>
212
+ <Search className="h-4 w-4" />
213
+ </Button>
214
+ </form>
215
+
216
+ {/* Results */}
217
+ <div className="space-y-4">
218
+ {isLoading ? (
219
+ <div className="flex items-center justify-center h-32">
220
+ <div className="text-muted-foreground">Searching...</div>
221
+ </div>
222
+ ) : gifResults.length > 0 ? (
223
+ <div className="grid grid-cols-2 gap-3">
224
+ {gifResults.map((gifUrl, index) => (
225
+ <div
226
+ key={index}
227
+ className="aspect-square bg-muted rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity border"
228
+ onClick={() => {
229
+ handleGifSelect(gifUrl);
230
+ }}
231
+ >
232
+ <img
233
+ src={gifUrl}
234
+ alt={`GIF ${String(index + 1)}`}
235
+ className="w-full h-full object-cover"
236
+ loading="lazy"
237
+ />
238
+ </div>
239
+ ))}
240
+ </div>
241
+ ) : searchQuery ? (
242
+ <div className="flex items-center justify-center h-32">
243
+ <div className="text-muted-foreground">No GIFs found</div>
244
+ </div>
245
+ ) : (
246
+ <div className="flex items-center justify-center h-32">
247
+ <div className="text-muted-foreground text-center">
248
+ Search for GIFs to get started
249
+ </div>
250
+ </div>
251
+ )}
252
+ </div>
253
+ </div>
254
+
255
+ <DrawerFooter>
256
+ <DrawerClose asChild>
257
+ <Button variant="outline">Cancel</Button>
258
+ </DrawerClose>
259
+ </DrawerFooter>
260
+ </div>
261
+ </DrawerContent>
262
+ </Drawer>
263
+ );
264
+ }