Work on profiles page

This commit is contained in:
2025-05-20 14:53:53 -05:00
parent d47ed16700
commit 3dffa71a89
9 changed files with 414 additions and 75 deletions

View File

@@ -2,11 +2,19 @@
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { getProfile, updateProfile, uploadFile } from '@/lib/actions';
import { getProfile, getSignedUrl, updateProfile, uploadFile } from '@/lib/actions';
import { useState, useEffect, useRef } from 'react';
import type { Profile } from '@/utils/supabase';
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
@@ -15,6 +23,7 @@ import {
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { toast } from 'sonner';
import { Pencil, User } from 'lucide-react'
@@ -28,6 +37,7 @@ const formSchema = z.object({
const ProfilePage = () => {
const [profile, setProfile] = useState<Profile | undefined>(undefined);
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -54,8 +64,7 @@ const ProfilePage = () => {
email: profileResponse.data.email ?? '',
});
} catch (error) {
console.error('Error getting profile:', error);
toast.error('Failed to load profile data');
setProfile(undefined);
} finally {
setIsLoading(false);
}
@@ -65,20 +74,81 @@ const ProfilePage = () => {
});
}, [form]);
useEffect(() => {
const getAvatarUrl = async () => {
if (profile?.avatar_url) {
try {
const response = await getSignedUrl({
bucket: 'avatars',
url: profile.avatar_url,
});
if (response.success) {
setAvatarUrl(response.data);
}
} catch (error) {
console.error('Error getting signed URL:', error);
}
}
};
getAvatarUrl().catch((error) => {
toast.error(error instanceof Error ? error.message : 'Failed to get signed avatar url.');
});
}, [profile]);
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file)
throw new Error('No file selected');
try {
setIsUploading(true);
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${profile?.id ?? Math.random().toString(36).substring(2,15)}.${fileExt}`;
const uploadResult = await uploadFile({
bucket: 'avatars',
path: fileName,
file,
options: {
upsert: true,
contentType: file.type,
},
});
if (!uploadResult.success)
throw new Error(uploadResult.error ?? 'Failed to upload avatar');
const updateResult = await updateProfile({
avatar_url: uploadResult.data,
});
if (!updateResult.success)
throw new Error(updateResult.error ?? 'Failed to update profile');
setProfile(updateResult.data);
toast.success('Avatar updated successfully.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to uploaad avatar.');
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
}
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
setIsLoading(true);
@@ -93,53 +163,120 @@ const ProfilePage = () => {
setProfile(result.data);
toast.success('Profile updated successfully!');
} catch (error) {
console.error('Error updating profile: ', error);
toast.error(error instanceof Error ? error.message : 'Failed to update profile.');
} finally {
setIsLoading(false);
}
};
if (profile === undefined)
return (
<div className='flex p-5 items-center justify-center'>
<h1>Unauthorized</h1>
</div>
);
return (
<Form { ...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-8'
>
<FormField
control={form.control}
name='full_name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Save</Button>
</form>
</Form>
<Card className='p-8'>
<CardHeader className='pb-2'>
<CardTitle className='text-2xl'>
{profile?.full_name ?? 'Profile'}
</CardTitle>
<CardDescription>
Manage your personal information & how it appears to others.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !profile ? (
<div className="flex justify-center py-8">
<div className="animate-pulse text-center">
<div className="h-24 w-24 rounded-full bg-gray-200 mx-auto mb-4"></div>
<div className="h-4 w-48 bg-gray-200 mx-auto"></div>
</div>
</div>
) : (
<div className="space-y-8">
<div className="flex flex-col items-center">
<div className="relative group cursor-pointer mb-4" onClick={handleAvatarClick}>
<Avatar className="h-32 w-32">
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={profile.full_name ?? 'User'} />
) : (
<AvatarFallback className="text-2xl">
{profile?.full_name
? profile.full_name.split(' ').map(n => n[0]).join('').toUpperCase()
: <User size={32} />}
</AvatarFallback>
)}
</Avatar>
<div className="absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50 transition-all flex items-center justify-center">
<Pencil className="text-white opacity-0 group-hover:opacity-100 transition-opacity" size={24} />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
disabled={isUploading}
/>
</div>
{isUploading && (
<div className="text-sm text-gray-500">Uploading...</div>
)}
<p className="text-sm text-gray-500 mt-2">
Click on the avatar to upload a new image
</p>
</div>
<Separator />
<Form { ...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-6'
>
<FormField
control={form.control}
name='full_name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-center">
<Button type='submit' disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Form>
</div>
)}
</CardContent>
</Card>
);
};
export default ProfilePage;

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,9 +1,11 @@
export * from '@/components/ui/avatar';
export * from '@/components/ui/badge';
export * from '@/components/ui/button';
export * from '@/components/ui/card';
export * from '@/components/ui/checkbox';
export * from '@/components/ui/dropdown-menu';
export * from '@/components/ui/form';
export * from '@/components/ui/input';
export * from '@/components/ui/label';
export * from '@/components/ui/separator';
export * from '@/components/ui/sonner';

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }