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,
|
"when": 1769077551375,
|
||||||
"tag": "0002_curly_spectrum",
|
"tag": "0002_curly_spectrum",
|
||||||
"breakpoints": true
|
"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 { withAuth } from "@workos-inc/authkit-nextjs"
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation"
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
@ -9,15 +9,31 @@ import {
|
|||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { ensureProfile } from "@/app/actions/profile"
|
||||||
|
|
||||||
import data from "./data.json"
|
import data from "./data.json"
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const { user } = await withAuth();
|
const { user } = await withAuth()
|
||||||
|
|
||||||
if (!user) {
|
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 (
|
return (
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
style={
|
style={
|
||||||
@ -27,7 +43,7 @@ export default async function Page() {
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<AppSidebar variant="inset" user={sidebarUser} />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex flex-1 flex-col">
|
<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 { SiteHeader } from "@/components/site-header"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { WishlistTable } from "@/components/wishlist/wishlist-table"
|
import { WishlistTable } from "@/components/wishlist/wishlist-table"
|
||||||
|
import { ensureProfile } from "@/app/actions/profile"
|
||||||
|
|
||||||
export default async function WishlistPage() {
|
export default async function WishlistPage() {
|
||||||
const { user } = await withAuth()
|
const { user } = await withAuth()
|
||||||
@ -13,10 +14,19 @@ export default async function WishlistPage() {
|
|||||||
redirect("/")
|
redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const userName =
|
const profile = await ensureProfile({
|
||||||
user.firstName && user.lastName
|
id: user.id,
|
||||||
? `${user.firstName} ${user.lastName}`
|
email: user.email ?? "",
|
||||||
: user.email || "Anonymous"
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
profilePictureUrl: user.profilePictureUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sidebarUser = {
|
||||||
|
name: profile.displayName,
|
||||||
|
email: profile.email,
|
||||||
|
avatar: profile.avatarUrl ?? "",
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
@ -27,12 +37,12 @@ export default async function WishlistPage() {
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<AppSidebar variant="inset" user={sidebarUser} />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
|
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
|
||||||
import { withAuth } from "@workos-inc/authkit-nextjs";
|
import { withAuth } from "@workos-inc/authkit-nextjs";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@ -28,12 +29,14 @@ export default async function RootLayout({
|
|||||||
const { accessToken: _, ...initialAuth } = auth;
|
const { accessToken: _, ...initialAuth } = auth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml"></link>
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml"></link>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<AuthKitProvider initialAuth={initialAuth}>{children}</AuthKitProvider>
|
<AuthKitProvider initialAuth={initialAuth}>{children}</AuthKitProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,11 +31,6 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
user: {
|
|
||||||
name: "shadcn",
|
|
||||||
email: "m@example.com",
|
|
||||||
avatar: "/avatars/shadcn.jpg",
|
|
||||||
},
|
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
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 (
|
return (
|
||||||
<Sidebar collapsible="offcanvas" {...props}>
|
<Sidebar collapsible="offcanvas" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@ -156,7 +155,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser user={user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
@ -28,6 +29,15 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} 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({
|
export function NavUser({
|
||||||
user,
|
user,
|
||||||
@ -39,6 +49,7 @@ export function NavUser({
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const initials = getInitials(user.name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@ -51,7 +62,7 @@ export function NavUser({
|
|||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<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">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
@ -84,9 +95,11 @@ export function NavUser({
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/dashboard/settings/profile">
|
||||||
<IconUserCircle />
|
<IconUserCircle />
|
||||||
Account
|
Account
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<IconCreditCard />
|
<IconCreditCard />
|
||||||
@ -98,7 +111,10 @@ export function NavUser({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleSignOut()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<IconLogout />
|
<IconLogout />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</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(),
|
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 WishlistItem = typeof wishlistItems.$inferSelect
|
||||||
export type WishlistVote = typeof wishlistVotes.$inferSelect
|
export type WishlistVote = typeof wishlistVotes.$inferSelect
|
||||||
export type WishlistComment = typeof wishlistComments.$inferSelect
|
export type WishlistComment = typeof wishlistComments.$inferSelect
|
||||||
export type WishlistCommentVote = typeof wishlistCommentVotes.$inferSelect
|
export type WishlistCommentVote = typeof wishlistCommentVotes.$inferSelect
|
||||||
|
export type UserProfile = typeof userProfiles.$inferSelect
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user