Not even sure but I'm sure it's better

This commit is contained in:
Gabriel Brown 2025-05-20 16:38:40 -05:00
parent 259aa46ef8
commit 0f92f7eb7f
19 changed files with 331 additions and 575 deletions

View File

@ -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>
)}

View File

@ -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;

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}
};

View File

@ -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...
</>
) : (

View File

@ -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 };

View File

@ -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,
}
};

View File

@ -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,
}
};

View File

@ -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,
}
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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!' };
}
};

View File

@ -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()

View File

@ -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,
};
}
};

View File

@ -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,
};
}
};

View File

@ -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,
};
}
};

View File

@ -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;