Add react hooks & components to split up the profile page. Learning how to separate hooks
This commit is contained in:
parent
3dffa71a89
commit
408bb140ba
@ -1,175 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { z } from 'zod';
|
import { useProfile } from '@/lib/hooks/useProfile';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { AvatarUpload, ProfileForm } from '@/components/default/profile';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { getProfile, getSignedUrl, updateProfile, uploadFile } from '@/lib/actions';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import type { Profile } from '@/utils/supabase';
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Form,
|
CardDescription,
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Input,
|
|
||||||
Separator,
|
Separator,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Pencil, User } from 'lucide-react'
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
full_name: z.string().min(5, {
|
|
||||||
message: 'Full name is required & must be at least 5 characters.'
|
|
||||||
}),
|
|
||||||
email: z.string().email(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
const { profile, isLoading, updateProfile } = useProfile();
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
|
const handleAvatarUploaded = async (path: string) => {
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
await updateProfile({ avatar_url: path });
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
full_name: '',
|
|
||||||
email: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProfile = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const profileResponse = await getProfile();
|
|
||||||
if (!profileResponse.success)
|
|
||||||
throw new Error('Profile response unsuccessful');
|
|
||||||
setProfile(profileResponse.data);
|
|
||||||
form.reset({
|
|
||||||
full_name: profileResponse.data.full_name ?? '',
|
|
||||||
email: profileResponse.data.email ?? '',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setProfile(undefined);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchProfile().catch((error) => {
|
|
||||||
console.error('Error getting profile:', error);
|
|
||||||
});
|
|
||||||
}, [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 handleProfileSubmit = async (values: { full_name: string; email: string }) => {
|
||||||
const file = e.target.files?.[0];
|
await updateProfile({
|
||||||
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);
|
|
||||||
const result = await updateProfile({
|
|
||||||
full_name: values.full_name,
|
full_name: values.full_name,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? 'Failed to update profile');
|
|
||||||
}
|
|
||||||
setProfile(result.data);
|
|
||||||
toast.success('Profile updated successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update profile.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (profile === undefined)
|
if (profile === undefined && !isLoading)
|
||||||
return (
|
return (
|
||||||
<div className='flex p-5 items-center justify-center'>
|
<div className='flex p-5 items-center justify-center'>
|
||||||
<h1>Unauthorized</h1>
|
<h1>Unauthorized</h1>
|
||||||
@ -177,106 +33,39 @@ const ProfilePage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='p-8'>
|
<div className="max-w-3xl mx-auto p-4">
|
||||||
<CardHeader className='pb-2'>
|
<Card className="mb-8">
|
||||||
<CardTitle className='text-2xl'>
|
<CardHeader className="pb-2">
|
||||||
{profile?.full_name ?? 'Profile'}
|
<CardTitle className="text-2xl">Your Profile</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage your personal information & how it appears to others.
|
Manage your personal information and how it appears to others
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading && !profile ? (
|
{isLoading && !profile ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="animate-pulse text-center">
|
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col items-center">
|
<AvatarUpload
|
||||||
<div className="relative group cursor-pointer mb-4" onClick={handleAvatarClick}>
|
profile={profile}
|
||||||
<Avatar className="h-32 w-32">
|
onAvatarUploaded={handleAvatarUploaded}
|
||||||
{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 />
|
<Separator />
|
||||||
<Form { ...form}>
|
|
||||||
<form
|
<ProfileForm
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
profile={profile}
|
||||||
className='space-y-6'
|
isLoading={isLoading}
|
||||||
>
|
onSubmit={handleProfileSubmit}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfilePage;
|
export default ProfilePage;
|
||||||
|
286
src/app/(auth-pages)/profile/page.tsx.bak
Normal file
286
src/app/(auth-pages)/profile/page.tsx.bak
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
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,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
Separator,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Pencil, User } from 'lucide-react'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
full_name: z.string().min(5, {
|
||||||
|
message: 'Full name is required & must be at least 5 characters.'
|
||||||
|
}),
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const profileResponse = await getProfile();
|
||||||
|
if (!profileResponse.success)
|
||||||
|
throw new Error('Profile response unsuccessful');
|
||||||
|
setProfile(profileResponse.data);
|
||||||
|
form.reset({
|
||||||
|
full_name: profileResponse.data.full_name ?? '',
|
||||||
|
email: profileResponse.data.email ?? '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setProfile(undefined);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProfile().catch((error) => {
|
||||||
|
console.error('Error getting profile:', error);
|
||||||
|
});
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getAvatarUrl = async () => {
|
||||||
|
if (profile?.avatar_url) {
|
||||||
|
try {
|
||||||
|
const response = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: profile.avatar_url,
|
||||||
|
transform: {
|
||||||
|
quality: 40,
|
||||||
|
resize: 'fill',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
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);
|
||||||
|
const result = await updateProfile({
|
||||||
|
full_name: values.full_name,
|
||||||
|
email: values.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
|
}
|
||||||
|
setProfile(result.data);
|
||||||
|
toast.success('Profile updated successfully!');
|
||||||
|
} catch (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 (
|
||||||
|
<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;
|
@ -3,8 +3,8 @@ import '@/styles/globals.css';
|
|||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ThemeProvider } from '@/components/context/theme';
|
import { ThemeProvider } from '@/components/context/theme';
|
||||||
import Navigation from '@/components/navigation';
|
import Navigation from '@/components/default/navigation';
|
||||||
import Footer from '@/components/footer';
|
import Footer from '@/components/default/footer';
|
||||||
import { Toaster } from '@/components/ui'
|
import { Toaster } from '@/components/ui'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { FetchDataSteps } from '@/components/tutorial';
|
import { FetchDataSteps } from '@/components/default/tutorial';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { getUser } from '@/lib/actions';
|
import { getUser } from '@/lib/actions';
|
||||||
import type { User } from '@/utils/supabase';
|
import type { User } from '@/utils/supabase';
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
@ -3,7 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
import { getProfile } from '@/lib/actions';
|
import { getProfile } from '@/lib/actions';
|
||||||
import AvatarDropdown from './avatar';
|
import AvatarDropdown from './AvatarDropdown';
|
||||||
|
|
||||||
const NavigationAuth = async () => {
|
const NavigationAuth = async () => {
|
||||||
try {
|
try {
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
import NavigationAuth from '@/components/navigation/auth';
|
import NavigationAuth from './auth';
|
||||||
import { ThemeToggle } from '@/components/context/theme';
|
import { ThemeToggle } from '@/components/context/theme';
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
71
src/components/default/profile/AvatarUpload.tsx
Normal file
71
src/components/default/profile/AvatarUpload.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||||
|
import { useAvatar } from '@/lib/hooks/useAvatar';
|
||||||
|
import type { Profile } from '@/utils/supabase';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui';
|
||||||
|
import { Pencil, User, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
type AvatarUploadProps = {
|
||||||
|
profile?: Profile;
|
||||||
|
onAvatarUploaded: (path: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvatarUpload = ({ profile, onAvatarUploaded }: AvatarUploadProps) => {
|
||||||
|
const { avatarUrl } = useAvatar(profile);
|
||||||
|
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const result = await uploadToStorage(file, 'avatars');
|
||||||
|
if (result.success && result.path) {
|
||||||
|
await onAvatarUploaded(result.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="flex items-center text-sm text-gray-500 mt-2">
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Click on the avatar to upload a new image
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
110
src/components/default/profile/ProfileForm.tsx
Normal file
110
src/components/default/profile/ProfileForm.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { Profile } from '@/utils/supabase';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
full_name: z.string().min(5, {
|
||||||
|
message: 'Full name is required & must be at least 5 characters.'
|
||||||
|
}),
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProfileFormProps = {
|
||||||
|
profile?: Profile;
|
||||||
|
isLoading: boolean;
|
||||||
|
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileForm({ profile, isLoading, onSubmit }: ProfileFormProps) {
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
full_name: profile?.full_name ?? '',
|
||||||
|
email: profile?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form values when profile changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
form.reset({
|
||||||
|
full_name: profile.full_name ?? '',
|
||||||
|
email: profile.email ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profile, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
await onSubmit(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
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-end">
|
||||||
|
<Button type='submit' disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
2
src/components/default/profile/index.tsx
Normal file
2
src/components/default/profile/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './AvatarUpload';
|
||||||
|
export * from './ProfileForm';
|
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui';
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TutorialStep, CodeBlock } from '@/components/tutorial';
|
import { TutorialStep, CodeBlock } from '@/components/default/tutorial';
|
||||||
|
|
||||||
const create = `create table notes (
|
const create = `create table notes (
|
||||||
id bigserial primary key,
|
id bigserial primary key,
|
3
src/components/default/tutorial/index.tsx
Normal file
3
src/components/default/tutorial/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CodeBlock } from './code-block';
|
||||||
|
export { FetchDataSteps } from './fetch-data-steps';
|
||||||
|
export { TutorialStep } from './tutorial-step';
|
@ -1,3 +0,0 @@
|
|||||||
export { CodeBlock } from '@/components/tutorial/code-block';
|
|
||||||
export { FetchDataSteps } from '@/components/tutorial/fetch-data-steps';
|
|
||||||
export { TutorialStep } from '@/components/tutorial/tutorial-step';
|
|
@ -157,7 +157,7 @@ export async function listFiles({
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
sortBy?: { column: string; order: 'asc' | 'desc' };
|
sortBy?: { column: string; order: 'asc' | 'desc' };
|
||||||
};
|
};
|
||||||
}): Promise<Result<Array<{ name: string; id: string; metadata: any }>>> {
|
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
@ -169,6 +169,7 @@ export async function listFiles({
|
|||||||
|
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Could not list files!', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
|
46
src/lib/hooks/useAvatar.ts
Normal file
46
src/lib/hooks/useAvatar.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getSignedUrl } from '@/lib/actions';
|
||||||
|
import type { Profile } from '@/utils/supabase';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useAvatar = (profile?: Profile) => {
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getAvatarUrl = async () => {
|
||||||
|
if (profile?.avatar_url) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: profile.avatar_url,
|
||||||
|
transform: {
|
||||||
|
quality: 20,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setAvatarUrl(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting signed URL:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getAvatarUrl().catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to get signed avatar url.');
|
||||||
|
});
|
||||||
|
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUrl,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
49
src/lib/hooks/useFileUpload.ts
Normal file
49
src/lib/hooks/useFileUpload.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { uploadFile } from '@/lib/actions';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useFileUpload = () => {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const uploadToStorage = async (file: File, bucket: string) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
// Generate a unique filename to avoid collisions
|
||||||
|
const fileExt = file.name.split('.').pop();
|
||||||
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExt}`;
|
||||||
|
|
||||||
|
// Upload the file to Supabase storage
|
||||||
|
const uploadResult = await uploadFile({
|
||||||
|
bucket,
|
||||||
|
path: fileName,
|
||||||
|
file,
|
||||||
|
options: {
|
||||||
|
upsert: true,
|
||||||
|
contentType: file.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, path: uploadResult.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error uploading to ${bucket}:`, error);
|
||||||
|
toast.error(error instanceof Error ? error.message : `Failed to upload to ${bucket}`);
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
// Clear the input value so the same file can be selected again
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUploading,
|
||||||
|
fileInputRef,
|
||||||
|
uploadToStorage,
|
||||||
|
};
|
||||||
|
}
|
57
src/lib/hooks/useProfile.ts
Normal file
57
src/lib/hooks/useProfile.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getProfile, updateProfile } from '@/lib/actions';
|
||||||
|
import type { Profile } from '@/utils/supabase';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function useProfile() {
|
||||||
|
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const profileResponse = await getProfile();
|
||||||
|
if (!profileResponse.success)
|
||||||
|
throw new Error('Profile response unsuccessful');
|
||||||
|
setProfile(profileResponse.data);
|
||||||
|
} catch (error) {
|
||||||
|
setProfile(undefined);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProfile().catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to get profile');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUserProfile = async (data: {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const result = await updateProfile(data);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
|
}
|
||||||
|
setProfile(result.data);
|
||||||
|
toast.success('Profile updated successfully!');
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile: ', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to update profile');
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
isLoading,
|
||||||
|
updateProfile: updateUserProfile,
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user