Made great progress on monorepo & auth for next. Very happy with work!

This commit is contained in:
2026-01-12 11:55:15 -06:00
parent 72f11f0b02
commit 321fecb5e1
58 changed files with 1266 additions and 222 deletions

View File

@@ -0,0 +1,221 @@
'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>
);
};