222 lines
6.7 KiB
TypeScript
222 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import type { Preloaded } from 'convex/react';
|
|
import type { ChangeEvent } from 'react';
|
|
import { useRef, useState } from 'react';
|
|
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
|
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
|
|
import { api } from '@gib/backend/convex/_generated/api.js';
|
|
import {
|
|
Avatar,
|
|
AvatarImage,
|
|
BasedAvatar,
|
|
Button,
|
|
CardContent,
|
|
ImageCrop,
|
|
ImageCropApply,
|
|
ImageCropContent,
|
|
Input,
|
|
} from '@gib/ui';
|
|
|
|
interface AvatarUploadProps {
|
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
|
}
|
|
|
|
const dataUrlToBlob = async (
|
|
dataUrl: string,
|
|
): Promise<{ blob: Blob; type: string }> => {
|
|
const re = /^data:([^;,]+)[;,]/;
|
|
const m = re.exec(dataUrl);
|
|
const type = m?.[1] ?? 'image/png';
|
|
|
|
const res = await fetch(dataUrl);
|
|
const blob = await res.blob();
|
|
return { blob, type };
|
|
};
|
|
|
|
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|
const user = usePreloadedQuery(preloadedUser);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
|
const updateUser = useMutation(api.auth.updateUser);
|
|
|
|
const currentImageUrl = useQuery(
|
|
api.files.getImageUrl,
|
|
user?.image ? { storageId: user.image } : 'skip',
|
|
);
|
|
|
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Please select an image file.');
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
return;
|
|
}
|
|
setSelectedFile(file);
|
|
setCroppedImage(null);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setSelectedFile(null);
|
|
setCroppedImage(null);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!croppedImage) {
|
|
toast.error('Please apply a crop first.');
|
|
return;
|
|
}
|
|
setIsUploading(true);
|
|
try {
|
|
const { blob, type } = await dataUrlToBlob(croppedImage);
|
|
const postUrl = await generateUploadUrl();
|
|
|
|
const result = await fetch(postUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': type },
|
|
body: blob,
|
|
});
|
|
if (!result.ok) {
|
|
const msg = await result.text().catch(() => 'Upload failed.');
|
|
throw new Error(msg);
|
|
}
|
|
|
|
const uploadResponse = (await result.json()) as {
|
|
storageId: Id<'_storage'>;
|
|
};
|
|
|
|
await updateUser({ image: uploadResponse.storageId });
|
|
|
|
toast.success('Profile picture updated.');
|
|
handleReset();
|
|
} catch (error) {
|
|
console.error('Upload failed:', error);
|
|
toast.error('Upload failed. Please try again.');
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<CardContent>
|
|
<div className="flex flex-col items-center gap-4">
|
|
{/* Current avatar + trigger (hidden when cropping) */}
|
|
{!selectedFile && (
|
|
<div
|
|
className="group relative cursor-pointer"
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
<BasedAvatar
|
|
src={currentImageUrl ?? undefined}
|
|
fullName={user?.name}
|
|
className="h-42 w-42 text-6xl font-semibold"
|
|
userIconProps={{ size: 100 }}
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50">
|
|
<Upload
|
|
className="text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
size={24}
|
|
/>
|
|
</div>
|
|
<div className="absolute inset-1 flex items-end justify-end transition-all">
|
|
<Pencil
|
|
className="text-white opacity-100 transition-opacity group-hover:opacity-0"
|
|
size={24}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File input (hidden) */}
|
|
<Input
|
|
ref={inputRef}
|
|
id="avatar-upload"
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
disabled={isUploading}
|
|
/>
|
|
|
|
{/* Crop UI */}
|
|
{selectedFile && !croppedImage && (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<ImageCrop
|
|
aspect={1}
|
|
circularCrop
|
|
file={selectedFile}
|
|
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
|
onCrop={setCroppedImage}
|
|
>
|
|
<ImageCropContent className="max-w-sm" />
|
|
<div className="flex items-center gap-2">
|
|
<ImageCropApply />
|
|
<Button
|
|
onClick={handleReset}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</ImageCrop>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cropped preview + actions */}
|
|
{croppedImage && (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Avatar className="h-42 w-42">
|
|
<AvatarImage alt="Cropped preview" src={croppedImage} />
|
|
</Avatar>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isUploading}
|
|
className="px-4"
|
|
>
|
|
{isUploading ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</span>
|
|
) : (
|
|
'Save Avatar'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
onClick={handleReset}
|
|
size="icon"
|
|
type="button"
|
|
className="hover:dark:bg-accent bg-red-400/80 hover:text-red-800/80 dark:bg-red-500/30 hover:dark:text-red-300/60"
|
|
variant="secondary"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploading indicator */}
|
|
{isUploading && !croppedImage && (
|
|
<div className="mt-2 flex items-center text-sm text-gray-500">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Uploading...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
);
|
|
};
|