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