mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-03-30 22:38:56 +00:00
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:
parent
bacf3d5d61
commit
b39329d432
13
drizzle/0003_talented_lockheed.sql
Normal file
13
drizzle/0003_talented_lockheed.sql
Normal 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
|
||||
);
|
||||
378
drizzle/meta/0003_snapshot.json
Normal file
378
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -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
190
src/app/actions/profile.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
67
src/app/dashboard/settings/profile/page.tsx
Normal file
67
src/app/dashboard/settings/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
263
src/components/settings/profile-form.tsx
Normal file
263
src/components/settings/profile-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
src/components/theme-provider.tsx
Normal file
10
src/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user