feat(settings): add user profile settings with theme toggle

Add user profile management system:
- Create user_profiles table with display name, bio, theme prefs
- Add profile settings page at /dashboard/settings/profile
- Integrate next-themes for light/dark/system theme switching
- Update sidebar to display user's profile data and avatar
- Wire up Account menu link and Log out button
This commit is contained in:
Nicholai Vogel 2026-01-22 05:41:11 -07:00
parent bacf3d5d61
commit b39329d432
13 changed files with 1013 additions and 26 deletions

View File

@ -0,0 +1,13 @@
CREATE TABLE `user_profiles` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`display_name` text,
`first_name` text,
`last_name` text,
`bio` text,
`avatar_url` text,
`theme` text DEFAULT 'system' NOT NULL,
`email_notifications` text DEFAULT 'true' NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);

View File

@ -0,0 +1,378 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b176d797-36df-41ea-be0c-9e4327ba74f6",
"prevId": "b77d7c90-2258-4123-9d06-632970d59286",
"tables": {
"user_profiles": {
"name": "user_profiles",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'system'"
},
"email_notifications": {
"name": "email_notifications",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'true'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wishlist_comment_votes": {
"name": "wishlist_comment_votes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"comment_id": {
"name": "comment_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"vote_type": {
"name": "vote_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"wishlist_comment_votes_comment_id_wishlist_comments_id_fk": {
"name": "wishlist_comment_votes_comment_id_wishlist_comments_id_fk",
"tableFrom": "wishlist_comment_votes",
"tableTo": "wishlist_comments",
"columnsFrom": [
"comment_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wishlist_comments": {
"name": "wishlist_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"wishlist_comments_item_id_wishlist_items_id_fk": {
"name": "wishlist_comments_item_id_wishlist_items_id_fk",
"tableFrom": "wishlist_comments",
"tableTo": "wishlist_items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wishlist_items": {
"name": "wishlist_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"estimated_cost": {
"name": "estimated_cost",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"link": {
"name": "link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"submitted_by": {
"name": "submitted_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"submitted_by_name": {
"name": "submitted_by_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wishlist_votes": {
"name": "wishlist_votes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"vote_type": {
"name": "vote_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"wishlist_votes_item_id_wishlist_items_id_fk": {
"name": "wishlist_votes_item_id_wishlist_items_id_fk",
"tableFrom": "wishlist_votes",
"tableTo": "wishlist_items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -22,6 +22,13 @@
"when": 1769077551375,
"tag": "0002_curly_spectrum",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1769083154035,
"tag": "0003_talented_lockheed",
"breakpoints": true
}
]
}

190
src/app/actions/profile.ts Normal file
View File

@ -0,0 +1,190 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { userProfiles } from "@/db/schema"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
export interface WorkOSUser {
id: string
email: string
firstName?: string | null
lastName?: string | null
profilePictureUrl?: string | null
}
export interface ProfileData {
id: string
email: string
displayName: string
firstName: string | null
lastName: string | null
bio: string | null
avatarUrl: string | null
theme: string
emailNotifications: boolean
}
export interface UpdateProfileInput {
displayName?: string
firstName?: string
lastName?: string
bio?: string
theme?: string
emailNotifications?: boolean
}
function computeDisplayName(
displayName: string | null | undefined,
firstName: string | null | undefined,
lastName: string | null | undefined,
email: string
): string {
if (displayName) return displayName
if (firstName && lastName) return `${firstName} ${lastName}`
if (firstName) return firstName
if (lastName) return lastName
return email.split("@")[0]
}
export async function ensureProfile(workosUser: WorkOSUser): Promise<ProfileData> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select()
.from(userProfiles)
.where(eq(userProfiles.id, workosUser.id))
.limit(1)
if (existing.length > 0) {
const profile = existing[0]
return {
id: profile.id,
email: profile.email,
displayName: computeDisplayName(
profile.displayName,
profile.firstName,
profile.lastName,
profile.email
),
firstName: profile.firstName,
lastName: profile.lastName,
bio: profile.bio,
avatarUrl: profile.avatarUrl ?? workosUser.profilePictureUrl ?? null,
theme: profile.theme,
emailNotifications: profile.emailNotifications === "true",
}
}
const now = new Date().toISOString()
await db.insert(userProfiles).values({
id: workosUser.id,
email: workosUser.email,
displayName: null,
firstName: workosUser.firstName ?? null,
lastName: workosUser.lastName ?? null,
bio: null,
avatarUrl: workosUser.profilePictureUrl ?? null,
theme: "system",
emailNotifications: "true",
createdAt: now,
updatedAt: now,
})
return {
id: workosUser.id,
email: workosUser.email,
displayName: computeDisplayName(
null,
workosUser.firstName,
workosUser.lastName,
workosUser.email
),
firstName: workosUser.firstName ?? null,
lastName: workosUser.lastName ?? null,
bio: null,
avatarUrl: workosUser.profilePictureUrl ?? null,
theme: "system",
emailNotifications: true,
}
}
export async function getProfile(userId: string): Promise<ProfileData | null> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const result = await db
.select()
.from(userProfiles)
.where(eq(userProfiles.id, userId))
.limit(1)
if (result.length === 0) return null
const profile = result[0]
return {
id: profile.id,
email: profile.email,
displayName: computeDisplayName(
profile.displayName,
profile.firstName,
profile.lastName,
profile.email
),
firstName: profile.firstName,
lastName: profile.lastName,
bio: profile.bio,
avatarUrl: profile.avatarUrl,
theme: profile.theme,
emailNotifications: profile.emailNotifications === "true",
}
}
export async function updateProfile(
userId: string,
input: UpdateProfileInput
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const updates: Record<string, string> = {
updatedAt: new Date().toISOString(),
}
if (input.displayName !== undefined) {
updates.displayName = input.displayName
}
if (input.firstName !== undefined) {
updates.firstName = input.firstName
}
if (input.lastName !== undefined) {
updates.lastName = input.lastName
}
if (input.bio !== undefined) {
updates.bio = input.bio
}
if (input.theme !== undefined) {
updates.theme = input.theme
}
if (input.emailNotifications !== undefined) {
updates.emailNotifications = input.emailNotifications ? "true" : "false"
}
await db
.update(userProfiles)
.set(updates)
.where(eq(userProfiles.id, userId))
revalidatePath("/dashboard/settings/profile")
revalidatePath("/dashboard")
revalidatePath("/dashboard/wishlist")
return { success: true }
} catch (error) {
console.error("Failed to update profile:", error)
return { success: false, error: "Failed to update profile" }
}
}

View File

@ -1,5 +1,5 @@
import { withAuth } from "@workos-inc/authkit-nextjs";
import { redirect } from "next/navigation";
import { withAuth } from "@workos-inc/authkit-nextjs"
import { redirect } from "next/navigation"
import { AppSidebar } from "@/components/app-sidebar"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
import { DataTable } from "@/components/data-table"
@ -9,15 +9,31 @@ import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { ensureProfile } from "@/app/actions/profile"
import data from "./data.json"
export default async function Page() {
const { user } = await withAuth();
const { user } = await withAuth()
if (!user) {
redirect("/");
redirect("/")
}
const profile = await ensureProfile({
id: user.id,
email: user.email ?? "",
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
})
const sidebarUser = {
name: profile.displayName,
email: profile.email,
avatar: profile.avatarUrl ?? "",
}
return (
<SidebarProvider
style={
@ -27,7 +43,7 @@ export default async function Page() {
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<AppSidebar variant="inset" user={sidebarUser} />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">

View File

@ -0,0 +1,67 @@
import { withAuth } from "@workos-inc/authkit-nextjs"
import { redirect } from "next/navigation"
import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { ProfileForm, ProfileFormSubmitButton } from "@/components/settings/profile-form"
import { ensureProfile } from "@/app/actions/profile"
export default async function ProfilePage() {
const { user } = await withAuth()
if (!user) {
redirect("/")
}
const profile = await ensureProfile({
id: user.id,
email: user.email ?? "",
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
})
const sidebarUser = {
name: profile.displayName,
email: profile.email,
avatar: profile.avatarUrl ?? "",
}
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" user={sidebarUser} />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="px-4 py-8 lg:px-8">
<div className="mx-auto max-w-2xl">
<ProfileForm profile={profile}>
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Profile Settings
</h1>
<p className="text-muted-foreground text-sm">
Manage your profile information and preferences
</p>
</div>
<ProfileFormSubmitButton />
</div>
</ProfileForm>
</div>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@ -5,6 +5,7 @@ import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { WishlistTable } from "@/components/wishlist/wishlist-table"
import { ensureProfile } from "@/app/actions/profile"
export default async function WishlistPage() {
const { user } = await withAuth()
@ -13,10 +14,19 @@ export default async function WishlistPage() {
redirect("/")
}
const userName =
user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email || "Anonymous"
const profile = await ensureProfile({
id: user.id,
email: user.email ?? "",
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
})
const sidebarUser = {
name: profile.displayName,
email: profile.email,
avatar: profile.avatarUrl ?? "",
}
return (
<SidebarProvider
@ -27,12 +37,12 @@ export default async function WishlistPage() {
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<AppSidebar variant="inset" user={sidebarUser} />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<WishlistTable userId={user.id} userName={userName} />
<WishlistTable userId={user.id} userName={profile.displayName} />
</div>
</div>
</SidebarInset>

View File

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
import { withAuth } from "@workos-inc/authkit-nextjs";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
@ -28,12 +29,14 @@ export default async function RootLayout({
const { accessToken: _, ...initialAuth } = auth;
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"></link>
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AuthKitProvider initialAuth={initialAuth}>{children}</AuthKitProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AuthKitProvider initialAuth={initialAuth}>{children}</AuthKitProvider>
</ThemeProvider>
</body>
</html>
);

View File

@ -31,11 +31,6 @@ import {
} from "@/components/ui/sidebar"
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Dashboard",
@ -132,7 +127,11 @@ const data = {
],
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
user: { name: string; email: string; avatar: string }
}
export function AppSidebar({ user, ...props }: AppSidebarProps) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@ -156,7 +155,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
)

View File

@ -1,5 +1,6 @@
"use client"
import Link from "next/link"
import {
IconCreditCard,
IconDotsVertical,
@ -28,6 +29,15 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { handleSignOut } from "@/app/actions/auth"
function getInitials(name: string): string {
const parts = name.split(" ").filter(Boolean)
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
export function NavUser({
user,
@ -39,6 +49,7 @@ export function NavUser({
}
}) {
const { isMobile } = useSidebar()
const initials = getInitials(user.name)
return (
<SidebarMenu>
@ -51,7 +62,7 @@ export function NavUser({
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
@ -72,7 +83,7 @@ export function NavUser({
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
@ -84,9 +95,11 @@ export function NavUser({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<IconUserCircle />
Account
<DropdownMenuItem asChild>
<Link href="/dashboard/settings/profile">
<IconUserCircle />
Account
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard />
@ -98,7 +111,10 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSignOut()}
className="cursor-pointer"
>
<IconLogout />
Log out
</DropdownMenuItem>

View File

@ -0,0 +1,263 @@
"use client"
import { createContext, useContext, useTransition } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { updateProfile, type ProfileData } from "@/app/actions/profile"
const ProfileFormContext = createContext<{ isPending: boolean } | null>(null)
function useProfileForm() {
const ctx = useContext(ProfileFormContext)
if (!ctx) throw new Error("useProfileForm must be used within ProfileForm")
return ctx
}
export function ProfileFormSubmitButton() {
const { isPending } = useProfileForm()
return (
<Button
type="submit"
form="profile-form"
disabled={isPending}
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-6"
>
{isPending ? "Saving..." : "Save Changes"}
</Button>
)
}
const formSchema = z.object({
displayName: z.string().max(50, "Display name too long").optional(),
firstName: z.string().max(50, "First name too long").optional(),
lastName: z.string().max(50, "Last name too long").optional(),
bio: z.string().max(500, "Bio too long").optional(),
theme: z.enum(["system", "light", "dark"]),
emailNotifications: z.boolean(),
})
type FormData = z.infer<typeof formSchema>
function getInitials(name: string): string {
const parts = name.split(" ").filter(Boolean)
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
interface ProfileFormProps {
profile: ProfileData
formId?: string
children?: React.ReactNode
}
export function ProfileForm({ profile, formId = "profile-form", children }: ProfileFormProps) {
const [isPending, startTransition] = useTransition()
const router = useRouter()
const { setTheme } = useTheme()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
displayName: profile.displayName === profile.email.split("@")[0]
? ""
: profile.displayName,
firstName: profile.firstName ?? "",
lastName: profile.lastName ?? "",
bio: profile.bio ?? "",
theme: profile.theme as "system" | "light" | "dark",
emailNotifications: profile.emailNotifications,
},
})
const onSubmit = (data: FormData) => {
startTransition(async () => {
const result = await updateProfile(profile.id, {
displayName: data.displayName || undefined,
firstName: data.firstName || undefined,
lastName: data.lastName || undefined,
bio: data.bio || undefined,
theme: data.theme,
emailNotifications: data.emailNotifications,
})
if (result.success) {
toast.success("Profile updated")
router.refresh()
} else {
toast.error(result.error || "Failed to update profile")
}
})
}
return (
<ProfileFormContext.Provider value={{ isPending }}>
{children}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Avatar</CardTitle>
<CardDescription>
Your avatar is managed through your authentication provider
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={profile.avatarUrl ?? ""} alt={profile.displayName} />
<AvatarFallback className="text-lg">
{getInitials(profile.displayName)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{profile.displayName}</p>
<p className="text-muted-foreground text-sm">{profile.email}</p>
</div>
</div>
</CardContent>
</Card>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your display name and personal details
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
placeholder={profile.displayName}
className="bg-muted/50 border-0"
{...form.register("displayName")}
/>
{form.formState.errors.displayName && (
<p className="text-destructive text-sm">
{form.formState.errors.displayName.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder={profile.firstName ?? ""}
className="bg-muted/50 border-0"
{...form.register("firstName")}
/>
{form.formState.errors.firstName && (
<p className="text-destructive text-sm">
{form.formState.errors.firstName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder={profile.lastName ?? ""}
className="bg-muted/50 border-0"
{...form.register("lastName")}
/>
{form.formState.errors.lastName && (
<p className="text-destructive text-sm">
{form.formState.errors.lastName.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
placeholder="Tell us a bit about yourself"
className="bg-muted/50 border-0 min-h-[100px]"
{...form.register("bio")}
/>
{form.formState.errors.bio && (
<p className="text-destructive text-sm">
{form.formState.errors.bio.message}
</p>
)}
</div>
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>
Customize your experience
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<Select
value={form.watch("theme")}
onValueChange={(value) => {
form.setValue("theme", value as FormData["theme"])
setTheme(value)
}}
>
<SelectTrigger id="theme" className="w-40 bg-muted/50 border-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="emailNotifications">Email Notifications</Label>
<p className="text-muted-foreground text-sm">
Receive email updates about activity
</p>
</div>
<Switch
id="emailNotifications"
checked={form.watch("emailNotifications")}
onCheckedChange={(checked) =>
form.setValue("emailNotifications", checked)
}
className="data-[state=checked]:bg-primary"
/>
</div>
</CardContent>
</Card>
</form>
</div>
</ProfileFormContext.Provider>
)
}

View File

@ -0,0 +1,10 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -45,7 +45,22 @@ export const wishlistCommentVotes = sqliteTable("wishlist_comment_votes", {
createdAt: text("created_at").notNull(),
})
export const userProfiles = sqliteTable("user_profiles", {
id: text("id").primaryKey(), // WorkOS user ID
email: text("email").notNull(),
displayName: text("display_name"),
firstName: text("first_name"),
lastName: text("last_name"),
bio: text("bio"),
avatarUrl: text("avatar_url"),
theme: text("theme").notNull().default("system"),
emailNotifications: text("email_notifications").notNull().default("true"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
})
export type WishlistItem = typeof wishlistItems.$inferSelect
export type WishlistVote = typeof wishlistVotes.$inferSelect
export type WishlistComment = typeof wishlistComments.$inferSelect
export type WishlistCommentVote = typeof wishlistCommentVotes.$inferSelect
export type UserProfile = typeof userProfiles.$inferSelect