Start adding profile page
This commit is contained in:
17
package.json
17
package.json
@ -16,13 +16,14 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@supabase/supabase-js": "^2.49.5",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -31,23 +32,25 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.24.4"
|
||||
"react-hook-form": "^7.56.4",
|
||||
"sonner": "^2.0.3",
|
||||
"zod": "^3.25.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.6",
|
||||
"@types/node": "^20.17.47",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/node": "^20.17.48",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1"
|
||||
},
|
||||
|
942
pnpm-lock.yaml
generated
942
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
145
src/app/(auth-pages)/profile/page.tsx
Normal file
145
src/app/(auth-pages)/profile/page.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { getProfile, updateProfile, uploadFile } from '@/lib/actions';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} 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 [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) {
|
||||
console.error('Error getting profile:', error);
|
||||
toast.error('Failed to load profile data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProfile().catch((error) => {
|
||||
console.error('Error getting profile:', error);
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
console.error('Error updating profile: ', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form { ...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='full_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit'>Save</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
export default ProfilePage;
|
@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ThemeProvider } from '@/components/context/theme';
|
||||
import Navigation from '@/components/navigation';
|
||||
import Footer from '@/components/footer';
|
||||
import { Toaster } from '@/components/ui'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'T3 Template with Supabase',
|
||||
@ -53,6 +54,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<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)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
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
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
@ -3,5 +3,7 @@ export * from '@/components/ui/badge';
|
||||
export * from '@/components/ui/button';
|
||||
export * from '@/components/ui/checkbox';
|
||||
export * from '@/components/ui/dropdown-menu';
|
||||
export * from '@/components/ui/form';
|
||||
export * from '@/components/ui/input';
|
||||
export * from '@/components/ui/label';
|
||||
export * from '@/components/ui/sonner';
|
||||
|
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
@ -27,3 +27,49 @@ export const getProfile = async (): Promise<Result<Profile>> => {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type updateProfileProps = {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
export const updateProfile = async ({
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (full_name === undefined && email === undefined && avatar_url === undefined)
|
||||
throw new Error('No profile data provided');
|
||||
|
||||
const userResponse = await getUser();
|
||||
if (!userResponse.success || userResponse.data === undefined)
|
||||
throw new Error('User not found');
|
||||
|
||||
const supabase = await createServerClient();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
...(full_name !== undefined && {full_name}),
|
||||
...(email !== undefined && {email}),
|
||||
...(avatar_url !== undefined && {avatar_url}),
|
||||
})
|
||||
.eq('id', userResponse.data.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return {
|
||||
success: true,
|
||||
data: data as Profile,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error updating profile',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user