use shadcn avatar component

This commit is contained in:
Leon van Zyl
2025-08-13 09:41:10 +02:00
parent 4eae69b8ce
commit 616ebb8fcc
6 changed files with 220 additions and 21 deletions

17
env.example Normal file
View File

@@ -0,0 +1,17 @@
# Rename this file to .env
# Database
DATABASE_URL="postgresql://username:password@localhost:5432/your_database_name"
# Authentication - Better Auth
BETTER_AUTH_SECRET="your-random-32-character-secret-key-here"
# Google OAuth (Get from Google Cloud Console)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# AI Integration (Optional - for chat functionality)
OPENAI_API_KEY="sk-your-openai-api-key-here"
# App URL (for production deployments)
NEXT_PUBLIC_APP_URL="http://localhost:3000"

View File

@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.9", "@ai-sdk/openai": "^2.0.9",
"@ai-sdk/react": "^2.0.9", "@ai-sdk/react": "^2.0.9",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"ai": "^5.0.9", "ai": "^5.0.9",
"better-auth": "^1.3.4", "better-auth": "^1.3.4",

112
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@ai-sdk/react': '@ai-sdk/react':
specifier: ^2.0.9 specifier: ^2.0.9
version: 2.0.9(react@19.1.0)(zod@4.0.17) version: 2.0.9(react@19.1.0)(zod@4.0.17)
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.9)(react@19.1.0) version: 1.2.3(@types/react@19.1.9)(react@19.1.0)
@@ -752,6 +755,19 @@ packages:
'@peculiar/asn1-x509@2.4.0': '@peculiar/asn1-x509@2.4.0':
resolution: {integrity: sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw==} resolution: {integrity: sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw==}
'@radix-ui/react-avatar@1.1.10':
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2': '@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies: peerDependencies:
@@ -761,6 +777,28 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3': '@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies: peerDependencies:
@@ -770,6 +808,33 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-is-hydrated@0.1.0':
resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -3010,12 +3075,40 @@ snapshots:
pvtsutils: 1.3.6 pvtsutils: 1.3.6
tslib: 2.8.1 tslib: 2.8.1
'@radix-ui/react-avatar@1.1.10(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.1.9)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.0)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.9)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.9)(react@19.1.0)': '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.9)(react@19.1.0)':
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
optionalDependencies: optionalDependencies:
'@types/react': 19.1.9 '@types/react': 19.1.9
'@radix-ui/react-context@1.1.2(@types/react@19.1.9)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.9
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.9)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.9)(react@19.1.0)': '@radix-ui/react-slot@1.2.3(@types/react@19.1.9)(react@19.1.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.0)
@@ -3023,6 +3116,25 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.9 '@types/react': 19.1.9
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.9)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.9
'@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.9)(react@19.1.0)':
dependencies:
react: 19.1.0
use-sync-external-store: 1.5.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.9
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.9)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.9
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {} '@rushstack/eslint-patch@1.12.0': {}

View File

@@ -4,10 +4,12 @@ import { useChat } from "@ai-sdk/react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UserProfile } from "@/components/auth/user-profile" import { UserProfile } from "@/components/auth/user-profile"
import { useSession } from "@/lib/auth-client" import { useSession } from "@/lib/auth-client"
import { useState } from "react"
export default function ChatPage() { export default function ChatPage() {
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession()
const { messages, input, handleInputChange, handleSubmit } = useChat() const { messages, sendMessage, status } = useChat()
const [input, setInput] = useState("")
if (isPending) { if (isPending) {
return <div className="flex justify-center items-center h-screen">Loading...</div> return <div className="flex justify-center items-center h-screen">Loading...</div>
@@ -56,14 +58,23 @@ export default function ChatPage() {
))} ))}
</div> </div>
<form onSubmit={handleSubmit} className="flex gap-2"> <form
onSubmit={(e) => {
e.preventDefault()
const text = input.trim()
if (!text) return
sendMessage({ text })
setInput("")
}}
className="flex gap-2"
>
<input <input
value={input} value={input}
onChange={handleInputChange} onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..." placeholder="Type your message..."
className="flex-1 p-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" className="flex-1 p-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/> />
<Button type="submit" disabled={!input.trim()}> <Button type="submit" disabled={!input.trim() || status === "streaming"}>
Send Send
</Button> </Button>
</form> </form>

View File

@@ -1,40 +1,45 @@
"use client" "use client";
import { useSession } from "@/lib/auth-client" import { useSession } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button" import { SignInButton } from "./sign-in-button";
import { SignOutButton } from "./sign-out-button" import { SignOutButton } from "./sign-out-button";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
export function UserProfile() { export function UserProfile() {
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession();
if (isPending) { if (isPending) {
return <div>Loading...</div> return <div>Loading...</div>;
} }
if (!session) { if (!session) {
return ( return (
<div className="flex flex-col items-center gap-4 p-6"> <div className="flex flex-col items-center gap-4 p-6">
<h2 className="text-xl font-semibold">Welcome</h2>
<p className="text-muted-foreground">Please sign in to continue</p>
<SignInButton /> <SignInButton />
</div> </div>
) );
} }
return ( return (
<div className="flex flex-col items-center gap-4 p-6"> <div className="flex flex-col items-center gap-4 p-6">
<div className="text-center"> <div className="text-center">
{session.user?.image && ( <Avatar className="size-16 mx-auto mb-4">
<img <AvatarImage
src={session.user.image} src={session.user?.image || ""}
alt={session.user.name || "User"} alt={session.user?.name || "User"}
className="w-16 h-16 rounded-full mx-auto mb-4"
/> />
)} <AvatarFallback>
{(
session.user?.name?.[0] ||
session.user?.email?.[0] ||
"U"
).toUpperCase()}
</AvatarFallback>
</Avatar>
<h2 className="text-xl font-semibold">{session.user?.name}</h2> <h2 className="text-xl font-semibold">{session.user?.name}</h2>
<p className="text-muted-foreground">{session.user?.email}</p> <p className="text-muted-foreground">{session.user?.email}</p>
</div> </div>
<SignOutButton /> <SignOutButton />
</div> </div>
) );
} }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }