good profile
This commit is contained in:
parent
9675a30969
commit
0288bf8eda
@ -1,7 +1,34 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'lh3.googleusercontent.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'avatars.githubusercontent.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.supabase.co',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.r2.cloudflarestorage.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
540
src/app/profile/page.tsx
Normal file
540
src/app/profile/page.tsx
Normal file
@ -0,0 +1,540 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { createClient } from '@/lib/supabase'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Avatar } from '@/components/ui/avatar'
|
||||||
|
import { Camera, Save, Eye, EyeOff, Globe } from 'lucide-react'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username?: string
|
||||||
|
bio?: string
|
||||||
|
avatar_url?: string
|
||||||
|
language?: 'en' | 'zh'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||||
|
const [isEditing, setIsEditing] = useState({
|
||||||
|
username: false,
|
||||||
|
email: false,
|
||||||
|
bio: false,
|
||||||
|
password: false
|
||||||
|
})
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
bio: '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
language: 'en' as 'en' | 'zh'
|
||||||
|
})
|
||||||
|
const [showPasswords, setShowPasswords] = useState({
|
||||||
|
current: false,
|
||||||
|
new: false,
|
||||||
|
confirm: false
|
||||||
|
})
|
||||||
|
const [saveStatus, setSaveStatus] = useState<{type: 'success' | 'error' | null, message: string}>({type: null, message: ''})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
loadProfile()
|
||||||
|
}
|
||||||
|
}, [user]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user metadata and profile data
|
||||||
|
const { data: { user: userData }, error: userError } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (userError) throw userError
|
||||||
|
|
||||||
|
const profileData: UserProfile = {
|
||||||
|
id: userData?.id || '',
|
||||||
|
email: userData?.email || '',
|
||||||
|
username: userData?.user_metadata?.username || userData?.user_metadata?.full_name || '',
|
||||||
|
bio: userData?.user_metadata?.bio || '',
|
||||||
|
avatar_url: userData?.user_metadata?.avatar_url || '',
|
||||||
|
language: userData?.user_metadata?.language || 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfile(profileData)
|
||||||
|
setFormData({
|
||||||
|
username: profileData.username || '',
|
||||||
|
email: profileData.email,
|
||||||
|
bio: profileData.bio || '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
language: profileData.language || 'en'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profile:', error)
|
||||||
|
setSaveStatus({type: 'error', message: 'Failed to load profile'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProfile = async (field: string, value: string) => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (field === 'email') {
|
||||||
|
const { error } = await supabase.auth.updateUser({ email: value })
|
||||||
|
if (error) throw error
|
||||||
|
setSaveStatus({type: 'success', message: 'Check your email to confirm the change'})
|
||||||
|
} else {
|
||||||
|
updates[field] = value
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
data: updates
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
setSaveStatus({type: 'success', message: `${field} updated successfully`})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload profile
|
||||||
|
await loadProfile()
|
||||||
|
|
||||||
|
// Reset editing state
|
||||||
|
setIsEditing(prev => ({ ...prev, [field]: false }))
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `Failed to update ${field}`})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePassword = async () => {
|
||||||
|
if (!formData.newPassword || formData.newPassword !== formData.confirmPassword) {
|
||||||
|
setSaveStatus({type: 'error', message: 'Passwords do not match'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.newPassword.length < 6) {
|
||||||
|
setSaveStatus({type: 'error', message: 'Password must be at least 6 characters'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
password: formData.newPassword
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
setSaveStatus({type: 'success', message: 'Password updated successfully'})
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}))
|
||||||
|
setIsEditing(prev => ({ ...prev, password: false }))
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to update password'})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadAvatar = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file || !user) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a unique filename for future use with actual file storage
|
||||||
|
// const fileExt = file.name.split('.').pop()
|
||||||
|
// const fileName = `${user.id}-${Date.now()}.${fileExt}`
|
||||||
|
|
||||||
|
// For now, we'll use a placeholder upload since we need to configure storage
|
||||||
|
// In a real implementation, you would upload to Supabase Storage or Cloudflare R2
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const dataUrl = e.target?.result as string
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
data: { avatar_url: dataUrl }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
await loadProfile()
|
||||||
|
setSaveStatus({type: 'success', message: 'Avatar updated successfully'})
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to upload avatar'})
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground mb-4">Access Denied</h1>
|
||||||
|
<p className="text-muted-foreground mb-4">Please sign in to access your profile</p>
|
||||||
|
<Button onClick={() => window.location.href = '/signin'}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground mb-2">Profile Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Manage your account settings and preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveStatus.type && (
|
||||||
|
<div className={`mb-6 p-4 rounded-lg border ${
|
||||||
|
saveStatus.type === 'success'
|
||||||
|
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300'
|
||||||
|
: 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||||
|
}`}>
|
||||||
|
{saveStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">Profile Picture</h2>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar
|
||||||
|
src={profile?.avatar_url}
|
||||||
|
alt="Profile Avatar"
|
||||||
|
size={96}
|
||||||
|
className="w-24 h-24"
|
||||||
|
/>
|
||||||
|
<label className="absolute -bottom-2 -right-2 bg-primary text-primary-foreground p-2 rounded-full cursor-pointer hover:bg-primary/90">
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={uploadAvatar}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 text-center">
|
||||||
|
Click the camera icon to upload a new picture
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Information */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Username */}
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Username</h3>
|
||||||
|
{!isEditing.username && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(prev => ({ ...prev, username: true }))}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing.username ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
placeholder="Enter username"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('username', formData.username)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(prev => ({ ...prev, username: false }))
|
||||||
|
setFormData(prev => ({ ...prev, username: profile?.username || '' }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.username || 'No username set'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Email</h3>
|
||||||
|
{!isEditing.email && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(prev => ({ ...prev, email: true }))}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing.email ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="Enter email"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('email', formData.email)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(prev => ({ ...prev, email: false }))
|
||||||
|
setFormData(prev => ({ ...prev, email: profile?.email || '' }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Bio</h3>
|
||||||
|
{!isEditing.bio && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(prev => ({ ...prev, bio: true }))}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing.bio ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
value={formData.bio}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
|
||||||
|
placeholder="Tell us about yourself"
|
||||||
|
className="min-h-[100px]"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-muted-foreground text-right">
|
||||||
|
{formData.bio.length}/500 characters
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('bio', formData.bio)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(prev => ({ ...prev, bio: false }))
|
||||||
|
setFormData(prev => ({ ...prev, bio: profile?.bio || '' }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.bio || 'No bio added yet'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language */}
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Language</h3>
|
||||||
|
<Globe className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={formData.language}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newLanguage = e.target.value as 'en' | 'zh'
|
||||||
|
setFormData(prev => ({ ...prev, language: newLanguage }))
|
||||||
|
updateProfile('language', newLanguage)
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md bg-input text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Password</h3>
|
||||||
|
{!isEditing.password && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(prev => ({ ...prev, password: true }))}
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing.password ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="newPassword">New Password</Label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type={showPasswords.new ? 'text' : 'password'}
|
||||||
|
value={formData.newPassword}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPasswords.new ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPasswords.confirm ? 'text' : 'password'}
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPasswords.confirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={updatePassword}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Update Password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(prev => ({ ...prev, password: false }))
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">••••••••</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -39,9 +39,9 @@ export function Header() {
|
|||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm text-gray-600">
|
<Button variant="ghost" size="sm" onClick={() => window.location.href = '/profile'}>
|
||||||
Welcome back!
|
Profile
|
||||||
</span>
|
</Button>
|
||||||
<Button variant="outline" onClick={signOut}>
|
<Button variant="outline" onClick={signOut}>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
@ -85,9 +85,9 @@ export function Header() {
|
|||||||
<div className="pt-4 pb-2">
|
<div className="pt-4 pb-2">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm text-gray-600 px-3">
|
<Button variant="ghost" className="w-full" onClick={() => window.location.href = '/profile'}>
|
||||||
Welcome back!
|
Profile
|
||||||
</div>
|
</Button>
|
||||||
<Button variant="outline" className="w-full" onClick={signOut}>
|
<Button variant="outline" className="w-full" onClick={signOut}>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
|
44
src/components/ui/avatar.tsx
Normal file
44
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { User } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ src, alt = "Avatar", size = 96, className = "" }: AvatarProps) {
|
||||||
|
const sizeClass = `w-${size/4} h-${size/4}`
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className={`${sizeClass} rounded-full bg-muted flex items-center justify-center overflow-hidden ${className}`}>
|
||||||
|
<User className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${sizeClass} rounded-full bg-muted flex items-center justify-center overflow-hidden ${className}`}>
|
||||||
|
{src.startsWith('data:') ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { TextareaHTMLAttributes, forwardRef } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
|
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-border bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
|
|
||||||
|
export { Textarea }
|
@ -28,6 +28,7 @@ export function useAuth() {
|
|||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
await supabase.auth.signOut()
|
await supabase.auth.signOut()
|
||||||
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
Loading…
Reference in New Issue
Block a user