Start adding profile page

This commit is contained in:
2025-05-19 16:57:52 -05:00
parent 40ab2d8450
commit d47ed16700
10 changed files with 666 additions and 680 deletions

View File

@ -16,13 +16,14 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.5",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -31,23 +32,25 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^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": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.6", "@tailwindcss/postcss": "^4.1.7",
"@types/node": "^20.17.47", "@types/node": "^20.17.48",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"eslint": "^9.26.0", "eslint": "^9.27.0",
"eslint-config-next": "^15.3.2", "eslint-config-next": "^15.3.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.6", "tailwindcss": "^4.1.7",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.9", "tw-animate-css": "^1.3.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.32.1" "typescript-eslint": "^8.32.1"
}, },

942
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

View File

@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme'; import { ThemeProvider } from '@/components/context/theme';
import Navigation from '@/components/navigation'; import Navigation from '@/components/navigation';
import Footer from '@/components/footer'; import Footer from '@/components/footer';
import { Toaster } from '@/components/ui'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'T3 Template with Supabase', title: 'T3 Template with Supabase',
@ -53,6 +54,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</div> </div>
<Footer /> <Footer />
</main> </main>
<Toaster />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

167
src/components/ui/form.tsx Normal file
View 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,
}

View File

@ -3,5 +3,7 @@ export * from '@/components/ui/badge';
export * from '@/components/ui/button'; export * from '@/components/ui/button';
export * from '@/components/ui/checkbox'; export * from '@/components/ui/checkbox';
export * from '@/components/ui/dropdown-menu'; export * from '@/components/ui/dropdown-menu';
export * from '@/components/ui/form';
export * from '@/components/ui/input'; export * from '@/components/ui/input';
export * from '@/components/ui/label'; export * from '@/components/ui/label';
export * from '@/components/ui/sonner';

View 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 }

View File

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