Not even sure but I'm sure it's better
This commit is contained in:
parent
259aa46ef8
commit
0f92f7eb7f
@ -18,7 +18,10 @@ const ProfilePage = () => {
|
||||
await updateProfile({ avatar_url: path });
|
||||
};
|
||||
|
||||
const handleProfileSubmit = async (values: { full_name: string; email: string }) => {
|
||||
const handleProfileSubmit = async (values: {
|
||||
full_name: string;
|
||||
email: string;
|
||||
}) => {
|
||||
await updateProfile({
|
||||
full_name: values.full_name,
|
||||
email: values.email,
|
||||
@ -31,34 +34,34 @@ const ProfilePage = () => {
|
||||
<h1>Unauthorized</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<Card className="mb-8">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">Your Profile</CardTitle>
|
||||
<div className='max-w-3xl min-w-sm mx-auto p-4'>
|
||||
<Card className='mb-8'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-2xl'>Your Profile</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your personal information and how it appears to others
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && !profile ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className='flex justify-center py-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<AvatarUpload
|
||||
profile={profile}
|
||||
onAvatarUploaded={handleAvatarUploaded}
|
||||
<div className='space-y-8'>
|
||||
<AvatarUpload
|
||||
profile={profile}
|
||||
onAvatarUploaded={handleAvatarUploaded}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ProfileForm
|
||||
profile={profile}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleProfileSubmit}
|
||||
<ProfileForm
|
||||
profile={profile}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleProfileSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,286 +0,0 @@
|
||||
'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;
|
@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ThemeProvider } from '@/components/context/theme';
|
||||
import Navigation from '@/components/default/navigation';
|
||||
import Footer from '@/components/default/footer';
|
||||
import { Toaster } from '@/components/ui'
|
||||
import { Toaster } from '@/components/ui';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'T3 Template with Supabase',
|
||||
|
@ -20,17 +20,21 @@ const HomePage = async () => {
|
||||
return (
|
||||
<div className='flex-1 w-full flex flex-col gap-12'>
|
||||
<div className='w-full'>
|
||||
<div className='bg-accent text-sm p-3 px-5
|
||||
rounded-md text-foreground flex gap-3 items-center'>
|
||||
<div
|
||||
className='bg-accent text-sm p-3 px-5
|
||||
rounded-md text-foreground flex gap-3 items-center'
|
||||
>
|
||||
<InfoIcon size='16' strokeWidth={2} />
|
||||
This is a protected component that you can only see as an authenticated
|
||||
user
|
||||
This is a protected component that you can only see as an
|
||||
authenticated user
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 items-start'>
|
||||
<h2 className='font-bold text-2xl mb-4'>Your user details</h2>
|
||||
<pre className='text-xs font-mono p-3 rounded
|
||||
border max-h-32 overflow-auto'>
|
||||
<pre
|
||||
className='text-xs font-mono p-3 rounded
|
||||
border max-h-32 overflow-auto'
|
||||
>
|
||||
{JSON.stringify(user, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui';
|
||||
import { useProfile, useAvatar } from '@/lib/hooks'
|
||||
import { useProfile, useAvatar } from '@/lib/hooks';
|
||||
import { signOut } from '@/lib/actions';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
@ -42,10 +42,16 @@ const AvatarDropdown = () => {
|
||||
{avatarUrl ? (
|
||||
<AvatarImage src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback className="text-2xl">
|
||||
{profile?.full_name
|
||||
? profile.full_name.split(' ').map(n => n[0]).join('').toUpperCase()
|
||||
: <User size={32} />}
|
||||
<AvatarFallback className='text-2xl'>
|
||||
{profile?.full_name ? (
|
||||
profile.full_name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
) : (
|
||||
<User size={32} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
|
@ -9,7 +9,10 @@ type AvatarUploadProps = {
|
||||
onAvatarUploaded: (path: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ profile, onAvatarUploaded }: AvatarUploadProps) => {
|
||||
export const AvatarUpload = ({
|
||||
profile,
|
||||
onAvatarUploaded,
|
||||
}: AvatarUploadProps) => {
|
||||
const { avatarUrl, isLoading } = useAvatar(profile);
|
||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||
|
||||
@ -20,7 +23,7 @@ export const AvatarUpload = ({ profile, onAvatarUploaded }: AvatarUploadProps) =
|
||||
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);
|
||||
@ -29,59 +32,77 @@ export const AvatarUpload = ({ profile, onAvatarUploaded }: AvatarUploadProps) =
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative group cursor-pointer mb-4">
|
||||
<Avatar>
|
||||
<AvatarFallback className="text-2xl">
|
||||
{profile?.full_name
|
||||
? profile.full_name.split(' ').map(n => n[0]).join('').toUpperCase()
|
||||
: <User size={32} />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='mb-4'>
|
||||
<Avatar className='h-32 w-32'>
|
||||
<AvatarFallback className='text-2xl'>
|
||||
{profile?.full_name ? (
|
||||
profile.full_name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
) : (
|
||||
<User size={32} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative group cursor-pointer mb-4" onClick={handleAvatarClick}>
|
||||
<Avatar className="h-32 w-32">
|
||||
<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 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"
|
||||
<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}
|
||||
<Pencil
|
||||
className='text-white opacity-0 group-hover:opacity-100
|
||||
transition-opacity'
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
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" />
|
||||
<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">
|
||||
<p className='text-sm text-gray-500 mt-2'>
|
||||
Click on the avatar to upload a new image
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -18,7 +18,7 @@ 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.'
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
}),
|
||||
email: z.string().email(),
|
||||
});
|
||||
@ -29,7 +29,11 @@ type ProfileFormProps = {
|
||||
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ProfileForm({ profile, isLoading, onSubmit }: ProfileFormProps) {
|
||||
export function ProfileForm({
|
||||
profile,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
}: ProfileFormProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@ -54,10 +58,7 @@ export function ProfileForm({ profile, isLoading, onSubmit }: ProfileFormProps)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='full_name'
|
||||
@ -67,14 +68,12 @@ export function ProfileForm({ profile, isLoading, onSubmit }: ProfileFormProps)
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your public display name.
|
||||
</FormDescription>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
@ -91,12 +90,12 @@ export function ProfileForm({ profile, isLoading, onSubmit }: ProfileFormProps)
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-center">
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button type='submit' disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
|
@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
@ -11,14 +11,14 @@ function Avatar({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
@ -27,11 +27,11 @@ function AvatarImage({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
data-slot='avatar-image'
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
@ -40,14 +40,14 @@ function AvatarFallback({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
@ -1,84 +1,84 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-slot='card'
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
'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">) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
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
|
||||
'@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">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
data-slot='card-title'
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot='card-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
data-slot='card-action'
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
data-slot='card-content'
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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)}
|
||||
data-slot='card-footer'
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
@ -1,23 +1,23 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@ -25,10 +25,10 @@ function DropdownMenuTrigger({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
data-slot='dropdown-menu-trigger'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@ -39,47 +39,47 @@ function DropdownMenuContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
data-slot='dropdown-menu-content'
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-slot='dropdown-menu-item'
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@ -90,22 +90,22 @@ function DropdownMenuCheckboxItem({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-slot='dropdown-menu-checkbox-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className='size-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@ -113,10 +113,10 @@ function DropdownMenuRadioGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
data-slot='dropdown-menu-radio-group'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@ -126,21 +126,21 @@ function DropdownMenuRadioItem({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-slot='dropdown-menu-radio-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@ -148,19 +148,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-slot='dropdown-menu-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@ -169,33 +169,33 @@ function DropdownMenuSeparator({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
data-slot='dropdown-menu-separator'
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
data-slot='dropdown-menu-shortcut'
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@ -204,22 +204,22 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-slot='dropdown-menu-sub-trigger'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className='ml-auto size-4' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@ -228,14 +228,14 @@ function DropdownMenuSubContent({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
data-slot='dropdown-menu-sub-content'
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -254,4 +254,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@ -11,23 +11,23 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@ -39,21 +39,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
@ -62,54 +62,55 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
data-slot='form-item'
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-slot='form-label'
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
data-slot='form-control'
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
@ -119,40 +120,40 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
data-slot='form-description'
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
data-slot='form-message'
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -164,4 +165,4 @@ export {
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
};
|
||||
|
@ -1,28 +1,28 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-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
|
||||
'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 }
|
||||
export { Separator };
|
||||
|
@ -1,25 +1,25 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className='toaster group'
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
@ -3,7 +3,7 @@
|
||||
import 'server-only';
|
||||
import { encodedRedirect } from '@/utils/utils';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { Result } from './index';
|
||||
@ -119,11 +119,7 @@ export const resetPassword = async (formData: FormData) => {
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
encodedRedirect(
|
||||
'error',
|
||||
'/reset-password',
|
||||
'Passwords do not match',
|
||||
);
|
||||
encodedRedirect('error', '/reset-password', 'Passwords do not match');
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
@ -131,11 +127,7 @@ export const resetPassword = async (formData: FormData) => {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
encodedRedirect(
|
||||
'error',
|
||||
'/reset-password',
|
||||
'Password update failed',
|
||||
);
|
||||
encodedRedirect('error', '/reset-password', 'Password update failed');
|
||||
}
|
||||
|
||||
encodedRedirect('success', '/reset-password', 'Password updated');
|
||||
@ -152,8 +144,8 @@ export const getUser = async (): Promise<Result<User>> => {
|
||||
const supabase = await createServerClient();
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
return {success: true, data: data.user};
|
||||
return { success: true, data: data.user };
|
||||
} catch (error) {
|
||||
return {success: false, error: 'Could not get user!'};
|
||||
return { success: false, error: 'Could not get user!' };
|
||||
}
|
||||
};
|
||||
|
@ -8,7 +8,8 @@ import type { Result } from './index';
|
||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||
try {
|
||||
const user = await getUser();
|
||||
if (!user.success || user.data === undefined) throw new Error('User not found');
|
||||
if (!user.success || user.data === undefined)
|
||||
throw new Error('User not found');
|
||||
const supabase = await createServerClient();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
@ -40,7 +41,11 @@ export const updateProfile = async ({
|
||||
avatar_url,
|
||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (full_name === undefined && email === undefined && avatar_url === undefined)
|
||||
if (
|
||||
full_name === undefined &&
|
||||
email === undefined &&
|
||||
avatar_url === undefined
|
||||
)
|
||||
throw new Error('No profile data provided');
|
||||
|
||||
const userResponse = await getUser();
|
||||
@ -51,9 +56,9 @@ export const updateProfile = async ({
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
...(full_name !== undefined && {full_name}),
|
||||
...(email !== undefined && {email}),
|
||||
...(avatar_url !== undefined && {avatar_url}),
|
||||
...(full_name !== undefined && { full_name }),
|
||||
...(email !== undefined && { email }),
|
||||
...(avatar_url !== undefined && { avatar_url }),
|
||||
})
|
||||
.eq('id', userResponse.data.id)
|
||||
.select()
|
||||
|
@ -17,9 +17,9 @@ export const useAvatar = (profile?: Profile) => {
|
||||
url: profile.avatar_url,
|
||||
transform: {
|
||||
quality: 20,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (response.success) {
|
||||
setAvatarUrl(response.data);
|
||||
}
|
||||
@ -32,15 +32,18 @@ export const useAvatar = (profile?: Profile) => {
|
||||
setAvatarUrl(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
getAvatarUrl().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to get signed avatar url.');
|
||||
});
|
||||
|
||||
getAvatarUrl().catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to get signed avatar url.',
|
||||
);
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
return {
|
||||
avatarUrl,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -9,11 +9,11 @@ export const useFileUpload = () => {
|
||||
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,
|
||||
@ -24,15 +24,19 @@ export const useFileUpload = () => {
|
||||
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}`);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload to ${bucket}`,
|
||||
);
|
||||
return { success: false, error };
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
@ -46,4 +50,4 @@ export const useFileUpload = () => {
|
||||
fileInputRef,
|
||||
uploadToStorage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -22,7 +22,9 @@ export const useProfile = () => {
|
||||
}
|
||||
};
|
||||
fetchProfile().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to get profile');
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to get profile',
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -42,7 +44,9 @@ export const useProfile = () => {
|
||||
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');
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update profile',
|
||||
);
|
||||
return { success: false, error };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -54,4 +58,4 @@ export const useProfile = () => {
|
||||
isLoading,
|
||||
updateProfile: updateUserProfile,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -46,7 +46,7 @@ export const updateSession = async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
//if (request.nextUrl.pathname === '/' && !user.error) {
|
||||
//return NextResponse.redirect(new URL('/protected', request.url));
|
||||
//return NextResponse.redirect(new URL('/protected', request.url));
|
||||
//}
|
||||
|
||||
return response;
|
||||
|
Loading…
x
Reference in New Issue
Block a user