@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,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
|
+
}
|