diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index 5f7c61c..fa68278 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -2,7 +2,11 @@ import { z } from "zod"; -import { createUser, getUser } from "@/lib/db/queries"; +import { + createPersonalizationByUserId, + createUser, + getUser, +} from "@/lib/db/queries"; import { signIn } from "./auth"; @@ -79,7 +83,7 @@ export const register = async ( } // Whenever we create a new user in dev, we set the user.type to dev - await createUser({ + const newUser = await createUser({ email: validatedData.email, password: validatedData.password, name: validatedData.name, @@ -87,6 +91,12 @@ export const register = async ( type: "dev", }); + await createPersonalizationByUserId({ + userId: newUser.id, + name: newUser.name, + email: newUser.email, + }); + await signIn("credentials", { email: validatedData.email, password: validatedData.password, diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index e8be056..2ca48dd 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -3,7 +3,11 @@ import NextAuth, { type DefaultSession } from "next-auth"; import type { DefaultJWT } from "next-auth/jwt"; import CredentialsProvider from "next-auth/providers/credentials"; import GoogleProvider from "next-auth/providers/google"; -import { createUser, getUser } from "@/lib/db/queries"; +import { + createPersonalizationByUserId, + createUser, + getUser, +} from "@/lib/db/queries"; import { authConfig } from "./auth.config"; export type UserType = "plus" | "pro" | "ultra" | "dev" | "free"; @@ -91,7 +95,7 @@ export const { } const [existingUser] = await getUser(user.email); - + // for Oauth providers if (account?.provider === "google") { if (existingUser) { @@ -107,6 +111,12 @@ export const { // Set the ID and type for the newly created user user.id = newUser.id; user.type = newUser.type; + + await createPersonalizationByUserId({ + userId: newUser.id, + name: newUser.name, + email: newUser.email, + }); } } diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts index 48352e9..8953755 100644 --- a/app/(chat)/api/chat/[id]/stream/route.ts +++ b/app/(chat)/api/chat/[id]/stream/route.ts @@ -7,7 +7,7 @@ import { getStreamIdsByChatId, } from "@/lib/db/queries"; import type { Chat } from "@/lib/db/schema"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; import type { ChatMessage } from "@/lib/types"; import { getStreamContext } from "../../route"; @@ -25,13 +25,13 @@ export async function GET( } if (!chatId) { - return new ChatSDKError("bad_request:api").toResponse(); + return new WitelyError("bad_request:api").toResponse(); } const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:chat").toResponse(); + return new WitelyError("unauthorized:chat").toResponse(); } let chat: Chat | null; @@ -39,27 +39,27 @@ export async function GET( try { chat = await getChatById({ id: chatId }); } catch { - return new ChatSDKError("not_found:chat").toResponse(); + return new WitelyError("not_found:chat").toResponse(); } if (!chat) { - return new ChatSDKError("not_found:chat").toResponse(); + return new WitelyError("not_found:chat").toResponse(); } if (chat.visibility === "private" && chat.userId !== session.user.id) { - return new ChatSDKError("forbidden:chat").toResponse(); + return new WitelyError("forbidden:chat").toResponse(); } const streamIds = await getStreamIdsByChatId({ chatId }); if (!streamIds.length) { - return new ChatSDKError("not_found:stream").toResponse(); + return new WitelyError("not_found:stream").toResponse(); } const recentStreamId = streamIds.at(-1); if (!recentStreamId) { - return new ChatSDKError("not_found:stream").toResponse(); + return new WitelyError("not_found:stream").toResponse(); } const emptyDataStream = createUIMessageStream({ diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 64cd2f6..17b17f8 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -46,7 +46,7 @@ import { saveMessages, updateChatLastContextById, } from "@/lib/db/queries"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; import type { ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { convertToUIMessages, generateUUID } from "@/lib/utils"; @@ -274,7 +274,7 @@ export async function POST(request: Request) { const json = await request.json(); requestBody = postRequestBodySchema.parse(json); } catch (_) { - return new ChatSDKError("bad_request:api").toResponse(); + return new WitelyError("bad_request:api").toResponse(); } try { @@ -293,7 +293,7 @@ export async function POST(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:chat").toResponse(); + return new WitelyError("unauthorized:chat").toResponse(); } const userType: UserType = session.user.type; @@ -338,14 +338,14 @@ export async function POST(request: Request) { }); if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { - return new ChatSDKError("rate_limit:chat").toResponse(); + return new WitelyError("rate_limit:chat").toResponse(); } const chat = await getChatById({ id }); if (chat) { if (chat.userId !== session.user.id) { - return new ChatSDKError("forbidden:chat").toResponse(); + return new WitelyError("forbidden:chat").toResponse(); } } else { const title = await generateTitleFromUserMessage({ @@ -545,7 +545,7 @@ export async function POST(request: Request) { } catch (error) { const vercelId = request.headers.get("x-vercel-id"); - if (error instanceof ChatSDKError) { + if (error instanceof WitelyError) { return error.toResponse(); } @@ -556,11 +556,11 @@ export async function POST(request: Request) { "AI Gateway requires a valid credit card on file to service requests" ) ) { - return new ChatSDKError("bad_request:activate_gateway").toResponse(); + return new WitelyError("bad_request:activate_gateway").toResponse(); } console.error("Unhandled error in chat API:", error, { vercelId }); - return new ChatSDKError("offline:chat").toResponse(); + return new WitelyError("offline:chat").toResponse(); } } @@ -569,19 +569,19 @@ export async function DELETE(request: Request) { const id = searchParams.get("id"); if (!id) { - return new ChatSDKError("bad_request:api").toResponse(); + return new WitelyError("bad_request:api").toResponse(); } const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:chat").toResponse(); + return new WitelyError("unauthorized:chat").toResponse(); } const chat = await getChatById({ id }); if (chat?.userId !== session.user.id) { - return new ChatSDKError("forbidden:chat").toResponse(); + return new WitelyError("forbidden:chat").toResponse(); } const deletedChat = await deleteChatById({ id }); diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index 0ea78ff..639c023 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -5,14 +5,14 @@ import { getDocumentsById, saveDocument, } from "@/lib/db/queries"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); if (!id) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter id is missing" ).toResponse(); @@ -21,7 +21,7 @@ export async function GET(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:document").toResponse(); + return new WitelyError("unauthorized:document").toResponse(); } const documents = await getDocumentsById({ id }); @@ -29,11 +29,11 @@ export async function GET(request: Request) { const [document] = documents; if (!document) { - return new ChatSDKError("not_found:document").toResponse(); + return new WitelyError("not_found:document").toResponse(); } if (document.userId !== session.user.id) { - return new ChatSDKError("forbidden:document").toResponse(); + return new WitelyError("forbidden:document").toResponse(); } return Response.json(documents, { status: 200 }); @@ -44,7 +44,7 @@ export async function POST(request: Request) { const id = searchParams.get("id"); if (!id) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter id is required." ).toResponse(); @@ -53,7 +53,7 @@ export async function POST(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("not_found:document").toResponse(); + return new WitelyError("not_found:document").toResponse(); } const { @@ -69,7 +69,7 @@ export async function POST(request: Request) { const [doc] = documents; if (doc.userId !== session.user.id) { - return new ChatSDKError("forbidden:document").toResponse(); + return new WitelyError("forbidden:document").toResponse(); } } @@ -90,14 +90,14 @@ export async function DELETE(request: Request) { const timestamp = searchParams.get("timestamp"); if (!id) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter id is required." ).toResponse(); } if (!timestamp) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter timestamp is required." ).toResponse(); @@ -106,7 +106,7 @@ export async function DELETE(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:document").toResponse(); + return new WitelyError("unauthorized:document").toResponse(); } const documents = await getDocumentsById({ id }); @@ -114,7 +114,7 @@ export async function DELETE(request: Request) { const [document] = documents; if (document.userId !== session.user.id) { - return new ChatSDKError("forbidden:document").toResponse(); + return new WitelyError("forbidden:document").toResponse(); } const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index 412f10f..3034761 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getChatsByUserId } from "@/lib/db/queries"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; @@ -11,7 +11,7 @@ export async function GET(request: NextRequest) { const endingBefore = searchParams.get("ending_before"); if (startingAfter && endingBefore) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Only one of starting_after or ending_before can be provided." ).toResponse(); @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:chat").toResponse(); + return new WitelyError("unauthorized:chat").toResponse(); } const chats = await getChatsByUserId({ diff --git a/app/(chat)/api/personalization/bio/route.ts b/app/(chat)/api/personalization/bio/route.ts new file mode 100644 index 0000000..e8a043a --- /dev/null +++ b/app/(chat)/api/personalization/bio/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import z from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getUser, updateBioByUserId } from "@/lib/db/queries"; + +const bioSchema = z.object({ + bio: z.string().max(500), +}); + +export async function POST(req: Request) { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [user] = await getUser(session.user.email); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { bio } = bioSchema.parse(await req.json()); + + await updateBioByUserId({ userId: user.id, bio }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", issues: error.errors }, + { status: 400 } + ); + } + console.error("Error posting user bio", error); + return NextResponse.json( + { error: "Failed to post user bio" }, + { status: 500 } + ); + } +} diff --git a/app/(chat)/api/personalization/personal-information/route.ts b/app/(chat)/api/personalization/personal-information/route.ts new file mode 100644 index 0000000..25edd9c --- /dev/null +++ b/app/(chat)/api/personalization/personal-information/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import z from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { + getAllPersonalizationsByUserId, + getUser, + updatePersonalInformationByUserId, +} from "@/lib/db/queries"; +import type { PersonalInformation } from "@/lib/db/types"; + +const updatesSchema = z.object({ + name: z.string().min(1).max(100).optional(), + email: z.string().email().max(256).optional(), + phone: z.string().max(20).optional(), + addressLine1: z.string().max(100).optional(), + addressLine2: z.string().max(100).optional(), + city: z.string().max(100).optional(), + state: z.string().max(100).optional(), + zipCode: z.string().max(20).optional(), + country: z.string().max(100).optional(), + gender: z + .enum(["Male", "Female", "Non-binary", "Prefer not to say", "Other"]) + .optional(), +}); + +export async function PATCH(req: Request) { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [user] = await getUser(session.user.email); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const updates = updatesSchema.parse(await req.json()); + + const records = await getAllPersonalizationsByUserId({ userId: user.id }); + const existingData = records[0]?.information; + + const merged = { + ...(existingData || {}), + ...updates, + } as PersonalInformation; + + await updatePersonalInformationByUserId({ + userId: user.id, + personalInformation: merged, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", issues: error.errors }, + { status: 400 } + ); + } + + console.error("Error patching personal information", error); + return NextResponse.json( + { error: "Failed to patch personal information" }, + { status: 500 } + ); + } +} diff --git a/app/(chat)/api/personalization/route.ts b/app/(chat)/api/personalization/route.ts new file mode 100644 index 0000000..eb49a31 --- /dev/null +++ b/app/(chat)/api/personalization/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getAllPersonalizationsByUserId, getUser } from "@/lib/db/queries"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [user] = await getUser(session.user.email); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const records = await getAllPersonalizationsByUserId({ userId: user.id }); + + if (!records[0]) { + return NextResponse.json( + { error: "Personalization data not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + personalization: { + ...records[0].information, + bio: records[0].bio, + }, + }); + } catch (error) { + console.error("Error getting personalizations", error); + return NextResponse.json( + { error: "Failed to get personalization" }, + { status: 500 } + ); + } +} diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts index 8801004..b70329d 100644 --- a/app/(chat)/api/suggestions/route.ts +++ b/app/(chat)/api/suggestions/route.ts @@ -1,13 +1,13 @@ import { auth } from "@/app/(auth)/auth"; import { getSuggestionsByDocumentId } from "@/lib/db/queries"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const documentId = searchParams.get("documentId"); if (!documentId) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter documentId is required." ).toResponse(); @@ -16,7 +16,7 @@ export async function GET(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:suggestions").toResponse(); + return new WitelyError("unauthorized:suggestions").toResponse(); } const suggestions = await getSuggestionsByDocumentId({ @@ -30,7 +30,7 @@ export async function GET(request: Request) { } if (suggestion.userId !== session.user.id) { - return new ChatSDKError("forbidden:api").toResponse(); + return new WitelyError("forbidden:api").toResponse(); } return Response.json(suggestions, { status: 200 }); diff --git a/app/(chat)/api/user/route.ts b/app/(chat)/api/user/route.ts new file mode 100644 index 0000000..cdb0e25 --- /dev/null +++ b/app/(chat)/api/user/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getUser } from "@/lib/db/queries"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [user] = await getUser(session.user.email); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ + id: user.id, + email: user.email, + name: user.name, + profileURL: user.profileURL, + type: user.type, + }); + } catch (error) { + console.error("Error fetching user:", error); + return NextResponse.json( + { error: "Failed to fetch user data" }, + { status: 500 } + ); + } +} diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts index 2c0ce3f..142f4b1 100644 --- a/app/(chat)/api/vote/route.ts +++ b/app/(chat)/api/vote/route.ts @@ -1,13 +1,13 @@ import { auth } from "@/app/(auth)/auth"; import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const chatId = searchParams.get("chatId"); if (!chatId) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameter chatId is required." ).toResponse(); @@ -16,17 +16,17 @@ export async function GET(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:vote").toResponse(); + return new WitelyError("unauthorized:vote").toResponse(); } const chat = await getChatById({ id: chatId }); if (!chat) { - return new ChatSDKError("not_found:chat").toResponse(); + return new WitelyError("not_found:chat").toResponse(); } if (chat.userId !== session.user.id) { - return new ChatSDKError("forbidden:vote").toResponse(); + return new WitelyError("forbidden:vote").toResponse(); } const votes = await getVotesByChatId({ id: chatId }); @@ -43,7 +43,7 @@ export async function PATCH(request: Request) { await request.json(); if (!chatId || !messageId || !type) { - return new ChatSDKError( + return new WitelyError( "bad_request:api", "Parameters chatId, messageId, and type are required." ).toResponse(); @@ -52,17 +52,17 @@ export async function PATCH(request: Request) { const session = await auth(); if (!session?.user) { - return new ChatSDKError("unauthorized:vote").toResponse(); + return new WitelyError("unauthorized:vote").toResponse(); } const chat = await getChatById({ id: chatId }); if (!chat) { - return new ChatSDKError("not_found:vote").toResponse(); + return new WitelyError("not_found:vote").toResponse(); } if (chat.userId !== session.user.id) { - return new ChatSDKError("forbidden:vote").toResponse(); + return new WitelyError("forbidden:vote").toResponse(); } await voteMessage({ diff --git a/app/globals.css b/app/globals.css index ae9191b..fc81ea9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -86,6 +86,43 @@ --sidebar: hsl(240 5.9% 10%); } +/* define design tokens (paper theme) */ +.paper { + --background: hsl(40 40% 96%); + --foreground: hsl(30 15% 20%); + --card: hsl(42 45% 94%); + --card-foreground: hsl(30 15% 20%); + --popover: hsl(42 45% 94%); + --popover-foreground: hsl(30 15% 20%); + --primary: hsl(31, 26%, 19%); + --primary-foreground: hsl(42 45% 94%); + --secondary: hsl(38 30% 88%); + --secondary-foreground: hsl(30 20% 30%); + --muted: hsl(38 30% 88%); + --muted-foreground: hsl(30 10% 45%); + --accent: hsl(35 35% 85%); + --accent-foreground: hsl(30 20% 30%); + --destructive: hsl(0 70% 55%); + --destructive-foreground: hsl(42 45% 94%); + --border: hsl(35 25% 82%); + --input: hsl(35 25% 82%); + --ring: hsl(30 20% 30%); + --chart-1: hsl(25 75% 55%); + --chart-2: hsl(160 50% 45%); + --chart-3: hsl(200 40% 40%); + --chart-4: hsl(45 70% 60%); + --chart-5: hsl(15 80% 60%); + --sidebar-background: hsl(39, 31%, 89%); + --sidebar-foreground: hsl(30 15% 25%); + --sidebar-primary: hsl(30 20% 30%); + --sidebar-primary-foreground: hsl(42 45% 94%); + --sidebar-accent: hsl(38 28% 88%); + --sidebar-accent-foreground: hsl(30 20% 30%); + --sidebar-border: hsl(35 22% 85%); + --sidebar-ring: hsl(25 70% 50%); + --sidebar: hsl(40, 27%, 91%); +} + /* define theme */ @theme { --font-sans: var(--font-geist); diff --git a/app/layout.tsx b/app/layout.tsx index bb74015..b3fa69b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Roboto, Roboto_Mono } from "next/font/google"; import { Toaster } from "sonner"; +import { SettingsModal } from "@/components/settings-modal"; import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; @@ -9,7 +10,8 @@ import { SessionProvider } from "next-auth/react"; export const metadata: Metadata = { metadataBase: new URL("https://chat.vercel.ai"), title: "Witely", - description: "Never Copy & Paste Again; Witely Gives You the Anwsers You Need, Before You Ask", + description: + "Never Copy & Paste Again; Witely Gives You the Anwsers You Need, Before You Ask", }; export const viewport = { @@ -30,6 +32,7 @@ const geistMono = Roboto_Mono({ const LIGHT_THEME_COLOR = "hsl(0 0% 100%)"; const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)"; +const PAPER_THEME_COLOR = "hsl(40 40% 96%)"; const THEME_COLOR_SCRIPT = `\ (function() { var html = document.documentElement; @@ -41,7 +44,9 @@ const THEME_COLOR_SCRIPT = `\ } function updateThemeColor() { var isDark = html.classList.contains('dark'); - meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}'); + var isPaper = html.classList.contains('paper'); + var color = isPaper ? '${PAPER_THEME_COLOR}' : (isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}'); + meta.setAttribute('content', color); } var observer = new MutationObserver(updateThemeColor); observer.observe(html, { attributes: true, attributeFilter: ['class'] }); @@ -72,15 +77,19 @@ export default function RootLayout({ /> - - - {children} - + + + + {children} + + + ); diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8835f3b..9a8fcd3 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -21,6 +21,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { Kbd, KbdGroup } from "./ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; export function AppSidebar({ user }: { user: User | undefined }) { @@ -59,7 +60,10 @@ export function AppSidebar({ user }: { user: User | undefined }) { - New Chat + New Chat{" "} + + ⌘o + diff --git a/components/chat.tsx b/components/chat.tsx index 1e7dfe7..f9e0b78 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -27,7 +27,7 @@ import { useAutoResume } from "@/hooks/use-auto-resume"; import { useChatVisibility } from "@/hooks/use-chat-visibility"; import { useMessages } from "@/hooks/use-messages"; import type { Vote } from "@/lib/db/schema"; -import { ChatSDKError } from "@/lib/errors"; +import { WitelyError } from "@/lib/errors"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; @@ -119,7 +119,7 @@ export function Chat({ mutate(unstable_serialize(getChatHistoryPaginationKey)); }, onError: (error) => { - if (error instanceof ChatSDKError) { + if (error instanceof WitelyError) { // Check if it's a credit card error if ( error.message?.includes("AI Gateway requires a valid credit card") diff --git a/components/settings-modal.tsx b/components/settings-modal.tsx new file mode 100644 index 0000000..3d78205 --- /dev/null +++ b/components/settings-modal.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect } from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useSettingsModal } from "@/hooks/use-settings-modal"; +import { SettingsDesktopLayout } from "./settings/settings-desktop-layout"; +import { SettingsMobileLayout } from "./settings/settings-mobile-layout"; + +export function SettingsModal() { + const { isOpen, close } = useSettingsModal(); + const isMobile = useIsMobile(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl + , to open settings + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + useSettingsModal.getState().toggle(); + } + // Escape to close + if (e.key === "Escape" && isOpen) { + e.preventDefault(); + close(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, close]); + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + return ( + + {isOpen && ( + <> + {/* Glassmorphic overlay */} + + + {/* Modal content */} +
+ + {isMobile ? ( + + ) : ( + + )} + +
+ + )} +
+ ); +} diff --git a/components/settings/sections/account-section.tsx b/components/settings/sections/account-section.tsx new file mode 100644 index 0000000..c8e3050 --- /dev/null +++ b/components/settings/sections/account-section.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useUser } from "@/hooks/use-user"; + +export function AccountSection() { + const { user, isLoading } = useUser(); + const [copied, setCopied] = useState(false); + const router = useRouter(); + + const handleCopyUUID = async () => { + if (user?.id) { + await navigator.clipboard.writeText(user?.id ?? ""); + setCopied(true); + + setTimeout(() => { + setCopied(false); + }, 3000); + } + }; + + return ( +
+ {/* Profile Section */} +
+ {isLoading ? ( + <> +
+ +
+ + +
+
+ + + ) : ( + <> +
+ + + + {user?.name?.[0]?.toUpperCase() || "U"} + + +
+

{user?.name || "User"}

+

+ {user?.email || "user@example.com"} +

+
+
+ + + )} +
+ + {/* Account Settings */} +
+
+ + {isLoading ? ( + + ) : ( +

+ {user?.type + ? user.type.charAt(0).toUpperCase() + user.type.slice(1) + : "Free"} +

+ )} +
+ + {/*
+ +

+ {isLoading ? ( + + ) : ( + new Date().toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) + )} +

+
*/} + +
+
+ +

+ Control your personal information and export your data. +

+
+ +
+ +
+
+ +

+ View and manage all your shared conversation links. +

+
+ +
+ +
+
+ +

+ Permanently remove all your account data. This action cannot be + undone. +

+
+ +
+ +
+
+ +

+ Permanently removes your account and all your data, including your + subscription. This action cannot be undone. Read the full + side-effects{" "} + + here + {" "} + before deleting your account. +

+
+ +
+
+
+ ); +} diff --git a/components/settings/sections/apps-section.tsx b/components/settings/sections/apps-section.tsx new file mode 100644 index 0000000..3451703 --- /dev/null +++ b/components/settings/sections/apps-section.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +const connectedApps = [ + { + id: "google-calendar", + name: "Google Calendar", + description: "Sync your calendar events and schedule", + connected: true, + }, + { + id: "slack", + name: "Slack", + description: "Send notifications and updates to Slack", + connected: false, + }, + { + id: "notion", + name: "Notion", + description: "Export and sync your notes with Notion", + connected: true, + }, + { + id: "github", + name: "GitHub", + description: "Connect your GitHub repositories", + connected: false, + }, +]; + +export function AppsSection() { + return ( +
+
+ +

+ Manage your connected applications and services. +

+
+ +
+ {connectedApps.map((app) => ( +
+
+
+ {app.name} + {app.connected && ( + + Connected + + )} +
+

{app.description}

+
+ +
+ ))} +
+
+ ); +} diff --git a/components/settings/sections/general-section.tsx b/components/settings/sections/general-section.tsx new file mode 100644 index 0000000..508fa9c --- /dev/null +++ b/components/settings/sections/general-section.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const themes = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "paper", label: "Paper" }, +]; + +const accentColors = [ + { value: "monochrome", label: "Monochrome" }, + { value: "orange", label: "Orange" }, + { value: "blue", label: "Blue" }, + { value: "green", label: "Green" }, + { value: "purple", label: "Purple" }, + { value: "pink", label: "Pink" }, +]; + +const accentColorMap: Record = { + monochrome: "#808080", + orange: "#f97316", + blue: "#3b82f6", + green: "#22c55e", + purple: "#a855f7", + pink: "#ec4899", +}; + +const languages = [ + { value: "auto", label: "Auto-detect" }, + { value: "en", label: "English" }, + { value: "es", label: "Spanish" }, + { value: "fr", label: "French" }, + { value: "de", label: "German" }, + { value: "ja", label: "Japanese" }, + { value: "zh", label: "Chinese" }, +]; + +export function GeneralSection() { + const { theme, setTheme } = useTheme(); + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +

+ For best results, select your primary language. +

+
+
+ ); +} diff --git a/components/settings/sections/notifications-section.tsx b/components/settings/sections/notifications-section.tsx new file mode 100644 index 0000000..5040f1f --- /dev/null +++ b/components/settings/sections/notifications-section.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export function NotificationsSection() { + const [promotional, setPromotional] = useState(true); + const [witelyTips, setWitelyTips] = useState(true); + + return ( +
+
+
+
+ +

+ Receive emails about new features, updates, and special offers. +

+
+ +
+ +
+
+ +

+ Get personalized suggestions, schedule summaries, weather updates, + and things you need to do for the day. +

+
+ +
+
+ +

+ You can learn more about Witely's privacy policy{" "} + + here + + . +

+
+ ); +} diff --git a/components/settings/sections/personalization-components/bio-display.tsx b/components/settings/sections/personalization-components/bio-display.tsx new file mode 100644 index 0000000..0d0087d --- /dev/null +++ b/components/settings/sections/personalization-components/bio-display.tsx @@ -0,0 +1,34 @@ +import { Edit2 } from "lucide-react"; +import { memo } from "react"; +import { Button } from "@/components/ui/button"; + +function BioDisplayComponent({ + bio, + onEdit, +}: { + bio: string; + onEdit: () => void; +}) { + return ( +
+
+

Bio

+ +
+ +
+ + {bio || "Tell Witely about yourself"} + +
+
+ ); +} + +const BioDisplay = memo(BioDisplayComponent); +BioDisplay.displayName = "BioDisplay"; + +export default BioDisplay; diff --git a/components/settings/sections/personalization-components/bio-edit.tsx b/components/settings/sections/personalization-components/bio-edit.tsx new file mode 100644 index 0000000..ae2c2eb --- /dev/null +++ b/components/settings/sections/personalization-components/bio-edit.tsx @@ -0,0 +1,59 @@ +import { memo } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +function BioEditComponent({ + bio, + onSave, + onCancel, + onBioChange, + isSaving, +}: { + bio: string; + onSave: () => void; + onCancel: () => void; + onBioChange: (value: string) => void; + isSaving?: boolean; +}) { + return ( +
+
+ +