better profie load
This commit is contained in:
parent
0288bf8eda
commit
a58b9222c3
@ -9,6 +9,8 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Avatar } from '@/components/ui/avatar'
|
import { Avatar } from '@/components/ui/avatar'
|
||||||
|
import { LoadingSpinner, LoadingOverlay } from '@/components/ui/loading-spinner'
|
||||||
|
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
|
||||||
import { Camera, Save, Eye, EyeOff, Globe } from 'lucide-react'
|
import { Camera, Save, Eye, EyeOff, Globe } from 'lucide-react'
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
@ -45,6 +47,9 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
const [saveStatus, setSaveStatus] = useState<{type: 'success' | 'error' | null, message: string}>({type: null, message: ''})
|
const [saveStatus, setSaveStatus] = useState<{type: 'success' | 'error' | null, message: string}>({type: null, message: ''})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [profileLoading, setProfileLoading] = useState(true)
|
||||||
|
const [fieldLoading, setFieldLoading] = useState<{[key: string]: boolean}>({})
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||||
|
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
@ -57,6 +62,7 @@ export default function ProfilePage() {
|
|||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
|
setProfileLoading(true)
|
||||||
try {
|
try {
|
||||||
// Get user metadata and profile data
|
// Get user metadata and profile data
|
||||||
const { data: { user: userData }, error: userError } = await supabase.auth.getUser()
|
const { data: { user: userData }, error: userError } = await supabase.auth.getUser()
|
||||||
@ -85,12 +91,15 @@ export default function ProfilePage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading profile:', error)
|
console.error('Error loading profile:', error)
|
||||||
setSaveStatus({type: 'error', message: 'Failed to load profile'})
|
setSaveStatus({type: 'error', message: 'Failed to load profile'})
|
||||||
|
} finally {
|
||||||
|
setProfileLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateProfile = async (field: string, value: string) => {
|
const updateProfile = async (field: string, value: string) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
|
setFieldLoading(prev => ({ ...prev, [field]: true }))
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setSaveStatus({type: null, message: ''})
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
@ -120,6 +129,7 @@ export default function ProfilePage() {
|
|||||||
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `Failed to update ${field}`})
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `Failed to update ${field}`})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setFieldLoading(prev => ({ ...prev, [field]: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +144,7 @@ export default function ProfilePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFieldLoading(prev => ({ ...prev, password: true }))
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setSaveStatus({type: null, message: ''})
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
@ -157,6 +168,7 @@ export default function ProfilePage() {
|
|||||||
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to update password'})
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to update password'})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setFieldLoading(prev => ({ ...prev, password: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +176,7 @@ export default function ProfilePage() {
|
|||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file || !user) return
|
if (!file || !user) return
|
||||||
|
|
||||||
|
setAvatarUploading(true)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setSaveStatus({type: null, message: ''})
|
setSaveStatus({type: null, message: ''})
|
||||||
|
|
||||||
@ -187,12 +200,14 @@ export default function ProfilePage() {
|
|||||||
await loadProfile()
|
await loadProfile()
|
||||||
setSaveStatus({type: 'success', message: 'Avatar updated successfully'})
|
setSaveStatus({type: 'success', message: 'Avatar updated successfully'})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setAvatarUploading(false)
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to upload avatar'})
|
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to upload avatar'})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setAvatarUploading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,297 +256,372 @@ export default function ProfilePage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Avatar Section */}
|
{/* Avatar Section */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-4">Profile Picture</h2>
|
<AvatarSkeleton />
|
||||||
<div className="flex flex-col items-center">
|
) : (
|
||||||
<div className="relative">
|
<LoadingOverlay isLoading={avatarUploading}>
|
||||||
<Avatar
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
src={profile?.avatar_url}
|
<h2 className="text-xl font-semibold text-foreground mb-4">Profile Picture</h2>
|
||||||
alt="Profile Avatar"
|
<div className="flex flex-col items-center">
|
||||||
size={96}
|
<div className="relative">
|
||||||
className="w-24 h-24"
|
<Avatar
|
||||||
/>
|
src={profile?.avatar_url}
|
||||||
<label className="absolute -bottom-2 -right-2 bg-primary text-primary-foreground p-2 rounded-full cursor-pointer hover:bg-primary/90">
|
alt="Profile Avatar"
|
||||||
<Camera className="w-4 h-4" />
|
size={96}
|
||||||
<input
|
className="w-24 h-24"
|
||||||
type="file"
|
/>
|
||||||
accept="image/*"
|
<label className={`absolute -bottom-2 -right-2 bg-primary text-primary-foreground p-2 rounded-full cursor-pointer hover:bg-primary/90 ${avatarUploading ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
className="hidden"
|
{avatarUploading ? (
|
||||||
onChange={uploadAvatar}
|
<LoadingSpinner size="sm" className="text-primary-foreground" />
|
||||||
disabled={isLoading}
|
) : (
|
||||||
/>
|
<Camera className="w-4 h-4" />
|
||||||
</label>
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={uploadAvatar}
|
||||||
|
disabled={isLoading || avatarUploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 text-center">
|
||||||
|
{avatarUploading ? 'Uploading...' : 'Click the camera icon to upload a new picture'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-2 text-center">
|
</LoadingOverlay>
|
||||||
Click the camera icon to upload a new picture
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Information */}
|
{/* Profile Information */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<FormFieldSkeleton />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Username</h3>
|
) : (
|
||||||
{!isEditing.username && (
|
<LoadingOverlay isLoading={fieldLoading.username}>
|
||||||
<Button
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
variant="outline"
|
<div className="flex items-center justify-between mb-4">
|
||||||
size="sm"
|
<h3 className="text-lg font-semibold text-foreground">Username</h3>
|
||||||
onClick={() => setIsEditing(prev => ({ ...prev, username: true }))}
|
{!isEditing.username && (
|
||||||
>
|
<Button
|
||||||
Edit
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
onClick={() => setIsEditing(prev => ({ ...prev, username: true }))}
|
||||||
</div>
|
disabled={isLoading || avatarUploading}
|
||||||
|
>
|
||||||
{isEditing.username ? (
|
Edit
|
||||||
<div className="space-y-4">
|
</Button>
|
||||||
<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>
|
||||||
|
|
||||||
|
{isEditing.username ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
placeholder="Enter username"
|
||||||
|
disabled={fieldLoading.username}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('username', formData.username)}
|
||||||
|
disabled={isLoading || fieldLoading.username}
|
||||||
|
>
|
||||||
|
{fieldLoading.username ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<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 || '' }))
|
||||||
|
}}
|
||||||
|
disabled={fieldLoading.username}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.username || 'No username set'}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</LoadingOverlay>
|
||||||
<p className="text-foreground">{profile?.username || 'No username set'}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<FormFieldSkeleton />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Email</h3>
|
) : (
|
||||||
{!isEditing.email && (
|
<LoadingOverlay isLoading={fieldLoading.email}>
|
||||||
<Button
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
variant="outline"
|
<div className="flex items-center justify-between mb-4">
|
||||||
size="sm"
|
<h3 className="text-lg font-semibold text-foreground">Email</h3>
|
||||||
onClick={() => setIsEditing(prev => ({ ...prev, email: true }))}
|
{!isEditing.email && (
|
||||||
>
|
<Button
|
||||||
Edit
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
onClick={() => setIsEditing(prev => ({ ...prev, email: true }))}
|
||||||
</div>
|
disabled={isLoading || avatarUploading}
|
||||||
|
>
|
||||||
{isEditing.email ? (
|
Edit
|
||||||
<div className="space-y-4">
|
</Button>
|
||||||
<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>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
disabled={fieldLoading.email}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('email', formData.email)}
|
||||||
|
disabled={isLoading || fieldLoading.email}
|
||||||
|
>
|
||||||
|
{fieldLoading.email ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<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 || '' }))
|
||||||
|
}}
|
||||||
|
disabled={fieldLoading.email}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.email}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</LoadingOverlay>
|
||||||
<p className="text-foreground">{profile?.email}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<TextAreaSkeleton />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Bio</h3>
|
) : (
|
||||||
{!isEditing.bio && (
|
<LoadingOverlay isLoading={fieldLoading.bio}>
|
||||||
<Button
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
variant="outline"
|
<div className="flex items-center justify-between mb-4">
|
||||||
size="sm"
|
<h3 className="text-lg font-semibold text-foreground">Bio</h3>
|
||||||
onClick={() => setIsEditing(prev => ({ ...prev, bio: true }))}
|
{!isEditing.bio && (
|
||||||
>
|
<Button
|
||||||
Edit
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
onClick={() => setIsEditing(prev => ({ ...prev, bio: true }))}
|
||||||
</div>
|
disabled={isLoading || avatarUploading}
|
||||||
|
>
|
||||||
{isEditing.bio ? (
|
Edit
|
||||||
<div className="space-y-4">
|
</Button>
|
||||||
<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>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
disabled={fieldLoading.bio}
|
||||||
|
/>
|
||||||
|
<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 || fieldLoading.bio}
|
||||||
|
>
|
||||||
|
{fieldLoading.bio ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<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 || '' }))
|
||||||
|
}}
|
||||||
|
disabled={fieldLoading.bio}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">{profile?.bio || 'No bio added yet'}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</LoadingOverlay>
|
||||||
<p className="text-foreground">{profile?.bio || 'No bio added yet'}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language */}
|
{/* Language */}
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<FormFieldSkeleton />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Language</h3>
|
) : (
|
||||||
<Globe className="w-5 h-5 text-muted-foreground" />
|
<LoadingOverlay isLoading={fieldLoading.language}>
|
||||||
</div>
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
<select
|
<h3 className="text-lg font-semibold text-foreground">Language</h3>
|
||||||
value={formData.language}
|
{fieldLoading.language ? (
|
||||||
onChange={(e) => {
|
<LoadingSpinner size="sm" />
|
||||||
const newLanguage = e.target.value as 'en' | 'zh'
|
) : (
|
||||||
setFormData(prev => ({ ...prev, language: newLanguage }))
|
<Globe className="w-5 h-5 text-muted-foreground" />
|
||||||
updateProfile('language', newLanguage)
|
)}
|
||||||
}}
|
</div>
|
||||||
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}
|
<select
|
||||||
>
|
value={formData.language}
|
||||||
<option value="en">English</option>
|
onChange={(e) => {
|
||||||
<option value="zh">中文</option>
|
const newLanguage = e.target.value as 'en' | 'zh'
|
||||||
</select>
|
setFormData(prev => ({ ...prev, language: newLanguage }))
|
||||||
</div>
|
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 || fieldLoading.language || avatarUploading}
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</LoadingOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div className="bg-card p-6 rounded-lg border border-border">
|
{profileLoading ? (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<FormFieldSkeleton />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Password</h3>
|
) : (
|
||||||
{!isEditing.password && (
|
<LoadingOverlay isLoading={fieldLoading.password}>
|
||||||
<Button
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
variant="outline"
|
<div className="flex items-center justify-between mb-4">
|
||||||
size="sm"
|
<h3 className="text-lg font-semibold text-foreground">Password</h3>
|
||||||
onClick={() => setIsEditing(prev => ({ ...prev, password: true }))}
|
{!isEditing.password && (
|
||||||
>
|
<Button
|
||||||
Change Password
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
onClick={() => setIsEditing(prev => ({ ...prev, password: true }))}
|
||||||
</div>
|
disabled={isLoading || avatarUploading}
|
||||||
|
|
||||||
{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" />}
|
Change Password
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{isEditing.password ? (
|
||||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
<div className="space-y-4">
|
||||||
<div className="relative mt-1">
|
<div>
|
||||||
<Input
|
<Label htmlFor="newPassword">New Password</Label>
|
||||||
id="confirmPassword"
|
<div className="relative mt-1">
|
||||||
type={showPasswords.confirm ? 'text' : 'password'}
|
<Input
|
||||||
value={formData.confirmPassword}
|
id="newPassword"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
type={showPasswords.new ? 'text' : 'password'}
|
||||||
placeholder="Confirm new password"
|
value={formData.newPassword}
|
||||||
className="pr-10"
|
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||||
/>
|
placeholder="Enter new password"
|
||||||
<button
|
className="pr-10"
|
||||||
type="button"
|
disabled={fieldLoading.password}
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
{showPasswords.confirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||||
</button>
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={fieldLoading.password}
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
disabled={fieldLoading.password}
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
disabled={fieldLoading.password}
|
||||||
|
>
|
||||||
|
{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 || fieldLoading.password}
|
||||||
|
>
|
||||||
|
{fieldLoading.password ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<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: ''
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
disabled={fieldLoading.password}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<p className="text-muted-foreground">••••••••</p>
|
||||||
<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>
|
</div>
|
||||||
) : (
|
</LoadingOverlay>
|
||||||
<p className="text-muted-foreground">••••••••</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
src/components/ui/loading-spinner.tsx
Normal file
47
src/components/ui/loading-spinner.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
className={cn(
|
||||||
|
'animate-spin text-muted-foreground',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
isLoading: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({ isLoading, children, className }: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
{children}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/components/ui/skeleton.tsx
Normal file
70
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-pulse rounded-md bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific skeleton components for different profile sections
|
||||||
|
export function ProfileCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<Skeleton className="h-6 w-32 mb-4" />
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<Skeleton className="w-24 h-24 rounded-full" />
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-primary/20">
|
||||||
|
<Skeleton className="w-full h-full rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-48 mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextAreaSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user