@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,829 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+
6
+ import { Camera, RotateCw, Upload } from "lucide-react";
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar.tsx";
9
+ import { Button } from "../ui/button.tsx";
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from "../ui/dropdown-menu.tsx";
16
+
17
+ /**
18
+ * Data structure returned when a user is updated
19
+ */
20
+ interface UserUpdateData {
21
+ /** User's unique identifier */
22
+ id: string;
23
+ /** User's display name */
24
+ displayName: string;
25
+ /** Optional profile picture URL */
26
+ profilePicture?: string;
27
+ }
28
+
29
+ /**
30
+ * Props for the ImageUpload component
31
+ */
32
+ interface ImageUploadProps {
33
+ /** User's unique identifier */
34
+ userId: string;
35
+ /** Current image URL or filename */
36
+ currentImage?: string;
37
+ /** Callback when image is successfully uploaded */
38
+ onImageUploaded: (fileName: string) => void;
39
+ /** Current user data (optional, for updating after upload) */
40
+ user?: {
41
+ id: string;
42
+ displayName: string;
43
+ profilePicture?: string;
44
+ };
45
+ /** Callback to update user data after upload */
46
+ updateUser?: (user: UserUpdateData) => void;
47
+ /**
48
+ * Base URL for API endpoints.
49
+ * @default ""
50
+ * @example "https://api.example.com" or "/api"
51
+ */
52
+ apiBaseUrl?: string;
53
+ /**
54
+ * Endpoint for uploading images.
55
+ * The endpoint should accept a FormData with 'image' and 'userId' fields.
56
+ * @default "/users/upload-image"
57
+ */
58
+ uploadEndpoint?: string;
59
+ /**
60
+ * Endpoint for fetching profile picture URLs.
61
+ * The userId will be appended to this endpoint.
62
+ * @default "/profile-picture"
63
+ */
64
+ profilePictureEndpoint?: string;
65
+ /**
66
+ * URL for the default/fallback image.
67
+ * @default "/images/default-profile.jpg"
68
+ */
69
+ defaultImageUrl?: string;
70
+ }
71
+
72
+ /**
73
+ * Crop area configuration for image editing
74
+ */
75
+ interface CropArea {
76
+ /** X position as percentage (0-100) */
77
+ x: number;
78
+ /** Y position as percentage (0-100) */
79
+ y: number;
80
+ /** Size of crop area as percentage (0-100) */
81
+ size: number;
82
+ }
83
+
84
+ /**
85
+ * An image upload component with camera capture, cropping, and rotation support.
86
+ *
87
+ * This component provides a complete image upload solution including:
88
+ * - File upload from device
89
+ * - Camera capture (with fallback)
90
+ * - Circular crop with drag-to-position
91
+ * - Image rotation
92
+ * - Zoom controls
93
+ * - Error handling with fallback image
94
+ *
95
+ * The component expects your backend to provide two endpoints:
96
+ * 1. Upload endpoint: Accepts POST with FormData (image, userId), returns { fileName: string }
97
+ * 2. Profile picture endpoint: GET endpoint that returns { url: string } for a given userId
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * function ProfileSettings() {
102
+ * const [user, setUser] = useState({ id: "123", displayName: "John" });
103
+ *
104
+ * return (
105
+ * <ImageUpload
106
+ * userId={user.id}
107
+ * currentImage={user.profilePicture}
108
+ * onImageUploaded={(fileName) => {
109
+ * console.log("Uploaded:", fileName);
110
+ * }}
111
+ * user={user}
112
+ * updateUser={setUser}
113
+ * apiBaseUrl="/api"
114
+ * />
115
+ * );
116
+ * }
117
+ * ```
118
+ *
119
+ * @example
120
+ * ```tsx
121
+ * // With custom endpoints
122
+ * <ImageUpload
123
+ * userId="123"
124
+ * onImageUploaded={handleUpload}
125
+ * apiBaseUrl="https://api.example.com"
126
+ * uploadEndpoint="/v2/upload-avatar"
127
+ * profilePictureEndpoint="/v2/avatar"
128
+ * defaultImageUrl="/assets/default-avatar.png"
129
+ * />
130
+ * ```
131
+ */
132
+ export function ImageUpload({
133
+ userId,
134
+ currentImage,
135
+ onImageUploaded,
136
+ user,
137
+ updateUser,
138
+ apiBaseUrl = "",
139
+ uploadEndpoint = "/users/upload-image",
140
+ profilePictureEndpoint = "/profile-picture",
141
+ defaultImageUrl = "/images/default-profile.jpg",
142
+ }: ImageUploadProps) {
143
+ const [preview, setPreview] = useState<string | null>(null);
144
+ const [uploading, setUploading] = useState(false);
145
+ const [imageError, setImageError] = useState(false);
146
+ const [uploadError, setUploadError] = useState(false);
147
+ const [imageUrl, setImageUrl] = useState<string>(defaultImageUrl);
148
+ const [showCamera, setShowCamera] = useState(false);
149
+ const [showCrop, setShowCrop] = useState(false);
150
+ const [originalImage, setOriginalImage] = useState<string | null>(null);
151
+ const [cropArea, setCropArea] = useState<CropArea>({
152
+ x: 50,
153
+ y: 50,
154
+ size: 80,
155
+ });
156
+ const [isDragging, setIsDragging] = useState(false);
157
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
158
+ const [rotation, setRotation] = useState(0);
159
+
160
+ const fileInputRef = useRef<HTMLInputElement>(null);
161
+ const cameraInputRef = useRef<HTMLInputElement>(null);
162
+ const videoRef = useRef<HTMLVideoElement>(null);
163
+ const canvasRef = useRef<HTMLCanvasElement>(null);
164
+ const streamRef = useRef<MediaStream | null>(null);
165
+ const cropImageRef = useRef<HTMLImageElement>(null);
166
+ const cropContainerRef = useRef<HTMLDivElement>(null);
167
+
168
+ // Fetch image URL when currentImage changes
169
+ useEffect(() => {
170
+ const fetchImageUrl = async () => {
171
+ if (preview) {
172
+ setImageUrl(preview);
173
+ return;
174
+ }
175
+
176
+ if (!currentImage) {
177
+ setImageUrl(defaultImageUrl);
178
+ return;
179
+ }
180
+
181
+ if (currentImage.startsWith("http")) {
182
+ setImageUrl(currentImage);
183
+ return;
184
+ }
185
+
186
+ try {
187
+ const endpoint = `${apiBaseUrl}${profilePictureEndpoint}/${userId}`;
188
+ const response = await fetch(endpoint);
189
+ if (response.ok) {
190
+ const data = (await response.json()) as { url: string };
191
+ setImageUrl(data.url);
192
+ } else {
193
+ setImageUrl(defaultImageUrl);
194
+ }
195
+ } catch (error) {
196
+ console.error("Error fetching image URL:", error);
197
+ setImageUrl(defaultImageUrl);
198
+ }
199
+ };
200
+
201
+ void fetchImageUrl();
202
+ }, [
203
+ currentImage,
204
+ preview,
205
+ userId,
206
+ apiBaseUrl,
207
+ profilePictureEndpoint,
208
+ defaultImageUrl,
209
+ ]);
210
+
211
+ // Cleanup camera stream when component unmounts
212
+ useEffect(() => {
213
+ return () => {
214
+ if (streamRef.current) {
215
+ streamRef.current.getTracks().forEach((track) => {
216
+ track.stop();
217
+ });
218
+ }
219
+ };
220
+ }, []);
221
+
222
+ // Prevent wheel events on body when crop modal is open
223
+ useEffect(() => {
224
+ if (showCrop) {
225
+ const preventWheel = (e: WheelEvent) => {
226
+ e.preventDefault();
227
+ e.stopPropagation();
228
+ return false;
229
+ };
230
+
231
+ // Add multiple event listeners to catch all wheel events
232
+ document.body.addEventListener("wheel", preventWheel, {
233
+ passive: false,
234
+ capture: true,
235
+ });
236
+ document.body.addEventListener("wheel", preventWheel, {
237
+ passive: false,
238
+ capture: false,
239
+ });
240
+ document.body.style.overflow = "hidden";
241
+
242
+ return () => {
243
+ document.body.removeEventListener("wheel", preventWheel, {
244
+ capture: true,
245
+ });
246
+ document.body.removeEventListener("wheel", preventWheel, {
247
+ capture: false,
248
+ });
249
+ document.body.style.overflow = "";
250
+ };
251
+ }
252
+ }, [showCrop]);
253
+
254
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
255
+ const target = e.target as HTMLInputElement;
256
+ const files = target.files;
257
+ const file = files?.[0];
258
+ if (!file) return;
259
+
260
+ // Create preview and show crop modal
261
+ const reader = new FileReader();
262
+ reader.onloadend = () => {
263
+ setOriginalImage(reader.result as string);
264
+ setShowCrop(true);
265
+ };
266
+ reader.readAsDataURL(file);
267
+ };
268
+
269
+ const handleCameraSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
270
+ const target = e.target as HTMLInputElement;
271
+ const files = target.files;
272
+ const file = files?.[0];
273
+ if (!file) return;
274
+
275
+ // Create preview and show crop modal
276
+ const reader = new FileReader();
277
+ reader.onloadend = () => {
278
+ setOriginalImage(reader.result as string);
279
+ setShowCrop(true);
280
+ };
281
+ reader.readAsDataURL(file);
282
+ };
283
+
284
+ const startCamera = async () => {
285
+ try {
286
+ const stream = await navigator.mediaDevices.getUserMedia({
287
+ video: {
288
+ facingMode: "user",
289
+ width: { ideal: 640 },
290
+ height: { ideal: 480 },
291
+ },
292
+ });
293
+
294
+ streamRef.current = stream;
295
+ setShowCamera(true);
296
+
297
+ // Set the video stream after the modal is rendered
298
+ setTimeout(() => {
299
+ if (videoRef.current) {
300
+ videoRef.current.srcObject = stream;
301
+ }
302
+ }, 100);
303
+ } catch (error) {
304
+ console.error("Error accessing camera:", error);
305
+ // Fallback to file input if camera access fails
306
+ if (cameraInputRef.current) {
307
+ cameraInputRef.current.click();
308
+ }
309
+ }
310
+ };
311
+
312
+ const stopCamera = () => {
313
+ if (streamRef.current) {
314
+ streamRef.current.getTracks().forEach((track) => {
315
+ track.stop();
316
+ });
317
+ streamRef.current = null;
318
+ }
319
+ setShowCamera(false);
320
+ };
321
+
322
+ const capturePhoto = (e?: React.MouseEvent) => {
323
+ e?.preventDefault();
324
+ e?.stopPropagation();
325
+ if (videoRef.current && canvasRef.current) {
326
+ const video = videoRef.current;
327
+ const canvas = canvasRef.current;
328
+ const context = canvas.getContext("2d");
329
+
330
+ if (context) {
331
+ // Set canvas size to match video
332
+ canvas.width = video.videoWidth;
333
+ canvas.height = video.videoHeight;
334
+
335
+ // Draw video frame to canvas
336
+ context.drawImage(video, 0, 0);
337
+
338
+ // Convert canvas to blob
339
+ canvas.toBlob(
340
+ (blob) => {
341
+ if (blob) {
342
+ const file = new File([blob], "camera-photo.jpg", {
343
+ type: "image/jpeg",
344
+ });
345
+ const reader = new FileReader();
346
+ reader.onloadend = () => {
347
+ setOriginalImage(reader.result as string);
348
+ setShowCrop(true);
349
+ };
350
+ reader.readAsDataURL(file);
351
+ setShowCamera(false);
352
+ }
353
+ },
354
+ "image/jpeg",
355
+ 0.8
356
+ );
357
+ }
358
+ }
359
+ };
360
+
361
+ const handleCropMouseDown = (e: React.MouseEvent) => {
362
+ e.preventDefault();
363
+ e.stopPropagation();
364
+ setIsDragging(true);
365
+ setDragStart({ x: e.clientX, y: e.clientY });
366
+ };
367
+
368
+ const handleCropMouseMove = (e: React.MouseEvent) => {
369
+ if (!isDragging || !cropContainerRef.current) return;
370
+
371
+ e.preventDefault();
372
+ e.stopPropagation();
373
+
374
+ const container = cropContainerRef.current.getBoundingClientRect();
375
+ const deltaX = e.clientX - dragStart.x;
376
+ const deltaY = e.clientY - dragStart.y;
377
+
378
+ setCropArea((prev) => ({
379
+ ...prev,
380
+ x: Math.max(0, Math.min(100, prev.x + (deltaX / container.width) * 100)),
381
+ y: Math.max(0, Math.min(100, prev.y + (deltaY / container.height) * 100)),
382
+ }));
383
+
384
+ setDragStart({ x: e.clientX, y: e.clientY });
385
+ };
386
+
387
+ const handleCropMouseUp = (e: React.MouseEvent) => {
388
+ e.preventDefault();
389
+ e.stopPropagation();
390
+ setIsDragging(false);
391
+ };
392
+
393
+ const handleCropResize = (direction: "in" | "out") => {
394
+ const delta = direction === "in" ? -2 : 2;
395
+ setCropArea((prev) => ({
396
+ ...prev,
397
+ size: Math.max(20, Math.min(100, prev.size + delta)),
398
+ }));
399
+ };
400
+
401
+ const rotateImage = () => {
402
+ setRotation((prev) => (prev + 90) % 360);
403
+ };
404
+
405
+ const cropImage = () => {
406
+ if (!originalImage || !cropImageRef.current) return;
407
+
408
+ const canvas = document.createElement("canvas");
409
+ const ctx = canvas.getContext("2d");
410
+ if (!ctx) return;
411
+
412
+ const img = cropImageRef.current;
413
+
414
+ // Wait for image to load
415
+ if (!img.complete || img.naturalWidth === 0) {
416
+ img.onload = () => {
417
+ cropImage();
418
+ };
419
+ return;
420
+ }
421
+
422
+ // Set canvas size for the final cropped image
423
+ const size = 400; // Final size for profile picture
424
+ canvas.width = size;
425
+ canvas.height = size;
426
+
427
+ // Get container dimensions
428
+ const container = cropContainerRef.current?.getBoundingClientRect();
429
+ if (!container) return;
430
+
431
+ // Calculate how the image is actually displayed (object-cover behavior)
432
+ const imgAspect = img.naturalWidth / img.naturalHeight;
433
+ const containerAspect = container.width / container.height;
434
+
435
+ let displayedWidth, displayedHeight, offsetX, offsetY;
436
+
437
+ if (imgAspect > containerAspect) {
438
+ // Image is wider than container - height fills container
439
+ displayedHeight = container.height;
440
+ displayedWidth = container.height * imgAspect;
441
+ offsetX = (container.width - displayedWidth) / 2;
442
+ offsetY = 0;
443
+ } else {
444
+ // Image is taller than container - width fills container
445
+ displayedWidth = container.width;
446
+ displayedHeight = container.width / imgAspect;
447
+ offsetX = 0;
448
+ offsetY = (container.height - displayedHeight) / 2;
449
+ }
450
+
451
+ // Calculate crop area in pixels relative to the displayed image
452
+ const cropSizePx =
453
+ (cropArea.size / 100) * Math.min(container.width, container.height);
454
+ const cropX = (cropArea.x / 100) * container.width - cropSizePx / 2;
455
+ const cropY = (cropArea.y / 100) * container.height - cropSizePx / 2;
456
+
457
+ // Convert crop coordinates to actual image coordinates
458
+ const scaleX = img.naturalWidth / displayedWidth;
459
+ const scaleY = img.naturalHeight / displayedHeight;
460
+
461
+ const actualCropX = (cropX - offsetX) * scaleX;
462
+ const actualCropY = (cropY - offsetY) * scaleY;
463
+ const actualCropSize = cropSizePx * scaleX;
464
+
465
+ // Apply rotation and crop to final canvas
466
+ ctx.save();
467
+ ctx.translate(size / 2, size / 2);
468
+ ctx.rotate((rotation * Math.PI) / 180);
469
+ ctx.beginPath();
470
+ ctx.arc(0, 0, size / 2, 0, 2 * Math.PI);
471
+ ctx.clip();
472
+ ctx.drawImage(
473
+ img,
474
+ actualCropX,
475
+ actualCropY,
476
+ actualCropSize,
477
+ actualCropSize,
478
+ -size / 2,
479
+ -size / 2,
480
+ size,
481
+ size
482
+ );
483
+ ctx.restore();
484
+
485
+ // Convert to blob and upload
486
+ canvas.toBlob(
487
+ (blob) => {
488
+ if (blob) {
489
+ const file = new File([blob], "cropped-profile.jpg", {
490
+ type: "image/jpeg",
491
+ });
492
+ void handleUpload(file);
493
+ setShowCrop(false);
494
+ setOriginalImage(null);
495
+ setRotation(0);
496
+ setCropArea({ x: 50, y: 50, size: 80 });
497
+ }
498
+ },
499
+ "image/jpeg",
500
+ 0.9
501
+ );
502
+ };
503
+
504
+ const handleUpload = async (file: File) => {
505
+ setUploading(true);
506
+ setUploadError(false);
507
+ try {
508
+ const formData = new FormData();
509
+ formData.append("image", file);
510
+ formData.append("userId", userId);
511
+
512
+ const endpoint = `${apiBaseUrl}${uploadEndpoint}`;
513
+ const response = await fetch(endpoint, {
514
+ method: "POST",
515
+ body: formData,
516
+ });
517
+
518
+ if (!response.ok) {
519
+ throw new Error("Failed to upload image");
520
+ }
521
+
522
+ const data = (await response.json()) as {
523
+ fileName: string;
524
+ user?: UserUpdateData;
525
+ };
526
+ onImageUploaded(data.fileName);
527
+
528
+ // Update the user data with the new profile picture
529
+ if (data.user && user && updateUser) {
530
+ updateUser(data.user);
531
+ }
532
+
533
+ // Clear preview after successful upload
534
+ setPreview(null);
535
+ } catch (error) {
536
+ console.error("Error uploading image:", error);
537
+ setUploadError(true);
538
+ } finally {
539
+ setUploading(false);
540
+ }
541
+ };
542
+
543
+ const handleUploadClick = () => {
544
+ if (fileInputRef.current) {
545
+ fileInputRef.current.click();
546
+ }
547
+ };
548
+
549
+ const handleCameraClick = () => {
550
+ // Try to use getUserMedia API first (better for desktop)
551
+ void startCamera();
552
+ };
553
+
554
+ const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
555
+ console.error("Error loading image:", e);
556
+ setImageError(true);
557
+ setImageUrl(defaultImageUrl);
558
+ };
559
+
560
+ return (
561
+ <div className="flex flex-col items-center gap-4">
562
+ <input
563
+ type="file"
564
+ ref={fileInputRef}
565
+ onChange={handleFileSelect}
566
+ accept="image/*"
567
+ className="hidden"
568
+ />
569
+ <input
570
+ type="file"
571
+ ref={cameraInputRef}
572
+ onChange={handleCameraSelect}
573
+ accept="image/*"
574
+ capture="user"
575
+ className="hidden"
576
+ />
577
+
578
+ {/* Camera Modal */}
579
+ {showCamera && (
580
+ <div
581
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
582
+ style={{ zIndex: 9999 }}
583
+ onClick={(e) => {
584
+ e.stopPropagation();
585
+ }}
586
+ >
587
+ <div
588
+ className="bg-background rounded-lg p-4 max-w-md w-full mx-4"
589
+ onClick={(e) => {
590
+ e.stopPropagation();
591
+ }}
592
+ >
593
+ <div className="text-center mb-4">
594
+ <h3 className="text-lg font-semibold">Take Photo</h3>
595
+ <p className="text-sm text-muted-foreground">
596
+ Position your face in the camera
597
+ </p>
598
+ </div>
599
+
600
+ <div className="relative">
601
+ <video
602
+ ref={videoRef}
603
+ autoPlay
604
+ playsInline
605
+ muted
606
+ className="w-full rounded-lg"
607
+ />
608
+ <canvas ref={canvasRef} className="hidden" />
609
+ </div>
610
+
611
+ <div className="flex gap-2 mt-4">
612
+ <Button
613
+ type="button"
614
+ variant="outline"
615
+ onClick={(e) => {
616
+ e.stopPropagation();
617
+ stopCamera();
618
+ }}
619
+ className="flex-1"
620
+ disabled={uploading}
621
+ >
622
+ Cancel
623
+ </Button>
624
+ <Button
625
+ type="button"
626
+ onClick={capturePhoto}
627
+ className="flex-1"
628
+ disabled={uploading}
629
+ >
630
+ {uploading ? "Uploading..." : "Take Photo"}
631
+ </Button>
632
+ </div>
633
+ </div>
634
+ </div>
635
+ )}
636
+
637
+ {/* Crop Modal */}
638
+ {showCrop && originalImage && (
639
+ <div
640
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
641
+ style={{ zIndex: 9999 }}
642
+ onClick={(e) => {
643
+ e.stopPropagation();
644
+ }}
645
+ onWheel={(e) => {
646
+ e.preventDefault();
647
+ e.stopPropagation();
648
+ }}
649
+ >
650
+ <div
651
+ className="bg-background rounded-lg p-4 max-w-lg w-full mx-4"
652
+ onClick={(e) => {
653
+ e.stopPropagation();
654
+ }}
655
+ >
656
+ <div className="text-center mb-4">
657
+ <h3 className="text-lg font-semibold">Crop Profile Picture</h3>
658
+ <p className="text-sm text-muted-foreground">
659
+ Drag to move, scroll to resize, rotate if needed
660
+ </p>
661
+ </div>
662
+
663
+ <div
664
+ ref={cropContainerRef}
665
+ className="relative w-full aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-move select-none"
666
+ style={{
667
+ userSelect: "none",
668
+ WebkitUserSelect: "none",
669
+ touchAction: "none",
670
+ }}
671
+ onMouseDown={handleCropMouseDown}
672
+ onMouseMove={handleCropMouseMove}
673
+ onMouseUp={handleCropMouseUp}
674
+ onMouseLeave={handleCropMouseUp}
675
+ onTouchStart={(e) => {
676
+ e.preventDefault();
677
+ }}
678
+ onTouchMove={(e) => {
679
+ e.preventDefault();
680
+ }}
681
+ >
682
+ <img
683
+ ref={cropImageRef}
684
+ src={originalImage}
685
+ alt="Crop"
686
+ className="w-full h-full object-cover"
687
+ style={{
688
+ transform: `rotate(${String(rotation)}deg)`,
689
+ }}
690
+ />
691
+
692
+ {/* Crop overlay */}
693
+ <div
694
+ className="absolute inset-0 pointer-events-none"
695
+ style={{
696
+ background: `radial-gradient(circle at ${String(cropArea.x)}% ${String(
697
+ cropArea.y
698
+ )}%, transparent ${String(cropArea.size / 2)}%, rgba(0, 0, 0, 0.5) ${String(
699
+ cropArea.size / 2
700
+ )}%)`,
701
+ }}
702
+ />
703
+
704
+ {/* Crop circle outline */}
705
+ <div
706
+ className="absolute border-2 border-white border-dashed pointer-events-none"
707
+ style={{
708
+ left: `${String(cropArea.x - cropArea.size / 2)}%`,
709
+ top: `${String(cropArea.y - cropArea.size / 2)}%`,
710
+ width: `${String(cropArea.size)}%`,
711
+ height: `${String(cropArea.size)}%`,
712
+ borderRadius: "50%",
713
+ }}
714
+ />
715
+ </div>
716
+
717
+ <div className="flex gap-2 mt-4">
718
+ <Button
719
+ type="button"
720
+ variant="outline"
721
+ onClick={() => {
722
+ setShowCrop(false);
723
+ setOriginalImage(null);
724
+ setRotation(0);
725
+ setCropArea({ x: 50, y: 50, size: 80 });
726
+ }}
727
+ className="flex-1"
728
+ disabled={uploading}
729
+ >
730
+ Cancel
731
+ </Button>
732
+ <Button
733
+ type="button"
734
+ variant="outline"
735
+ onClick={rotateImage}
736
+ disabled={uploading}
737
+ >
738
+ <RotateCw className="h-4 w-4" />
739
+ </Button>
740
+ </div>
741
+
742
+ <div className="flex gap-2 mt-2">
743
+ <Button
744
+ type="button"
745
+ variant="outline"
746
+ size="sm"
747
+ onClick={() => {
748
+ handleCropResize("out");
749
+ }}
750
+ disabled={uploading}
751
+ className="flex-1"
752
+ >
753
+ Zoom Out
754
+ </Button>
755
+ <Button
756
+ type="button"
757
+ variant="outline"
758
+ size="sm"
759
+ onClick={() => {
760
+ handleCropResize("in");
761
+ }}
762
+ disabled={uploading}
763
+ className="flex-1"
764
+ >
765
+ Zoom In
766
+ </Button>
767
+ </div>
768
+
769
+ <div className="flex gap-2 mt-2">
770
+ <Button
771
+ type="button"
772
+ onClick={cropImage}
773
+ className="flex-1"
774
+ disabled={uploading}
775
+ >
776
+ {uploading ? "Uploading..." : "Crop & Save"}
777
+ </Button>
778
+ </div>
779
+ </div>
780
+ </div>
781
+ )}
782
+
783
+ <div className="relative">
784
+ <Avatar className="h-24 w-24">
785
+ <AvatarImage
786
+ src={imageUrl}
787
+ alt="Profile"
788
+ onError={handleImageError}
789
+ />
790
+ <AvatarFallback>
791
+ {(userId.length > 0 ? userId[0] : "?").toUpperCase()}
792
+ </AvatarFallback>
793
+ </Avatar>
794
+ <DropdownMenu>
795
+ <DropdownMenuTrigger asChild>
796
+ <Button
797
+ type="button"
798
+ size="icon"
799
+ variant="secondary"
800
+ className="absolute bottom-0 right-0 rounded-full"
801
+ disabled={uploading}
802
+ >
803
+ <Camera className="h-4 w-4" />
804
+ </Button>
805
+ </DropdownMenuTrigger>
806
+ <DropdownMenuContent align="end">
807
+ <DropdownMenuItem onClick={handleUploadClick}>
808
+ <Upload className="mr-2 h-4 w-4" />
809
+ Upload Photo
810
+ </DropdownMenuItem>
811
+ <DropdownMenuItem onClick={handleCameraClick}>
812
+ <Camera className="mr-2 h-4 w-4" />
813
+ Take Photo
814
+ </DropdownMenuItem>
815
+ </DropdownMenuContent>
816
+ </DropdownMenu>
817
+ </div>
818
+ {uploading && (
819
+ <p className="text-sm text-muted-foreground">Uploading...</p>
820
+ )}
821
+ {uploadError && (
822
+ <p className="text-sm text-red-500">Failed to upload image</p>
823
+ )}
824
+ {imageError && (
825
+ <p className="text-sm text-red-500">Failed to load image</p>
826
+ )}
827
+ </div>
828
+ );
829
+ }