819 lines
34 KiB
TypeScript
819 lines
34 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { useBetterAuth } from '@/hooks/useBetterAuth'
|
|
import { changePassword } from '@/lib/auth-client'
|
|
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 { LegacyAvatar } from '@/components/ui/avatar'
|
|
import { LoadingSpinner, LoadingOverlay } from '@/components/ui/loading-spinner'
|
|
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
|
|
import { Save, Eye, EyeOff, CreditCard, Crown, Star } from 'lucide-react'
|
|
|
|
interface UserProfile {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
username?: string
|
|
bio?: string
|
|
image?: string // Better Auth field
|
|
versionLimit: number
|
|
subscribePlan: string
|
|
maxVersionLimit: number
|
|
promptLimit?: number
|
|
creditBalance: number
|
|
createdAt?: string
|
|
updatedAt?: string
|
|
}
|
|
|
|
interface CreditInfo {
|
|
totalBalance: number
|
|
activeCredits: {
|
|
id: string
|
|
amount: number
|
|
type: string
|
|
note: string | null
|
|
expiresAt: Date | null
|
|
createdAt: Date
|
|
}[]
|
|
expiredCredits: {
|
|
id: string
|
|
amount: number
|
|
type: string
|
|
note: string | null
|
|
expiresAt: Date | null
|
|
createdAt: Date
|
|
}[]
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const { user, loading, triggerProfileUpdate } = useBetterAuth()
|
|
const t = useTranslations('profile')
|
|
const tCommon = useTranslations('common')
|
|
const tAuth = useTranslations('auth')
|
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
|
const [isEditing, setIsEditing] = useState({
|
|
username: false,
|
|
email: false,
|
|
bio: false,
|
|
password: false,
|
|
versionLimit: false
|
|
})
|
|
const [formData, setFormData] = useState({
|
|
username: '',
|
|
email: '',
|
|
bio: '',
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
versionLimit: 3
|
|
})
|
|
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 [profileLoading, setProfileLoading] = useState(true)
|
|
const [fieldLoading, setFieldLoading] = useState<{ [key: string]: boolean }>({})
|
|
const [creditInfo, setCreditInfo] = useState<CreditInfo | null>(null)
|
|
|
|
|
|
const loadProfile = useCallback(async () => {
|
|
if (!user) return
|
|
|
|
setProfileLoading(true)
|
|
try {
|
|
// Get profile data from our API
|
|
const [profileResponse, creditsResponse] = await Promise.all([
|
|
fetch(`/api/users/profile?userId=${user.id}`),
|
|
fetch(`/api/users/credits?userId=${user.id}`)
|
|
])
|
|
|
|
if (!profileResponse.ok) {
|
|
throw new Error('Failed to fetch profile')
|
|
}
|
|
|
|
const profileData: UserProfile = await profileResponse.json()
|
|
|
|
setProfile(profileData)
|
|
setFormData({
|
|
username: profileData.name || '', // 直接使用name字段
|
|
email: profileData.email,
|
|
bio: profileData.bio || '',
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
versionLimit: profileData.versionLimit
|
|
})
|
|
|
|
// Load credit information
|
|
if (creditsResponse.ok) {
|
|
const creditData: CreditInfo = await creditsResponse.json()
|
|
setCreditInfo(creditData)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading profile:', error)
|
|
setSaveStatus({ type: 'error', message: t('failedToLoadProfile') })
|
|
} finally {
|
|
setProfileLoading(false)
|
|
}
|
|
}, [user, t])
|
|
|
|
useEffect(() => {
|
|
if (!user) return
|
|
loadProfile()
|
|
}, [user, loadProfile])
|
|
|
|
const updateProfile = async (field: string, value: string | number) => {
|
|
if (!user) return
|
|
|
|
setFieldLoading(prev => ({ ...prev, [field]: true }))
|
|
setIsLoading(true)
|
|
setSaveStatus({ type: null, message: '' })
|
|
|
|
try {
|
|
// Use our API for all fields
|
|
const updateData: Record<string, unknown> = { userId: user.id }
|
|
|
|
// 用户名字段映射到name字段
|
|
if (field === 'username') {
|
|
updateData['name'] = value // Better Auth的name字段
|
|
} else {
|
|
updateData[field] = value
|
|
}
|
|
|
|
const response = await fetch('/api/users/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updateData)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new Error(errorData.error || 'Failed to update profile')
|
|
}
|
|
|
|
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
|
|
|
|
// Reload profile and trigger cache update
|
|
await Promise.all([
|
|
loadProfile(),
|
|
triggerProfileUpdate()
|
|
])
|
|
|
|
// Reset editing state
|
|
setIsEditing(prev => ({ ...prev, [field]: false }))
|
|
|
|
} catch (error: unknown) {
|
|
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `${t('failedToUpdate')} ${field}` })
|
|
} finally {
|
|
setIsLoading(false)
|
|
setFieldLoading(prev => ({ ...prev, [field]: false }))
|
|
}
|
|
}
|
|
|
|
const updatePassword = async () => {
|
|
if (!formData.newPassword || formData.newPassword !== formData.confirmPassword) {
|
|
setSaveStatus({ type: 'error', message: t('passwordsNotMatch') })
|
|
return
|
|
}
|
|
|
|
if (formData.newPassword.length < 6) {
|
|
setSaveStatus({ type: 'error', message: t('passwordMinLength') })
|
|
return
|
|
}
|
|
|
|
setFieldLoading(prev => ({ ...prev, password: true }))
|
|
setIsLoading(true)
|
|
setSaveStatus({ type: null, message: '' })
|
|
|
|
try {
|
|
// 使用Better Auth的changePassword方法
|
|
const { data, error } = await changePassword({
|
|
newPassword: formData.newPassword,
|
|
currentPassword: formData.currentPassword,
|
|
revokeOtherSessions: false, // 保持其他会话不被撤销
|
|
})
|
|
|
|
if (error) {
|
|
throw new Error(error.message || 'Failed to change password')
|
|
}
|
|
|
|
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
|
|
setFormData({ ...formData, currentPassword: '', newPassword: '', confirmPassword: '' })
|
|
|
|
} catch (error: unknown) {
|
|
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
|
|
} finally {
|
|
setIsLoading(false)
|
|
setFieldLoading(prev => ({ ...prev, password: false }))
|
|
}
|
|
}
|
|
|
|
|
|
// Show skeleton screens immediately when auth is loading
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<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">{t('title')}</h1>
|
|
<p className="text-muted-foreground">{t('subtitle')}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
|
|
<div className="lg:col-span-1 space-y-6">
|
|
<AvatarSkeleton />
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<FormFieldSkeleton />
|
|
<FormFieldSkeleton />
|
|
<TextAreaSkeleton />
|
|
<FormFieldSkeleton />
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<Header />
|
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold text-foreground mb-4">{t('accessDenied')}</h1>
|
|
<p className="text-muted-foreground mb-4">{t('pleaseSignIn')}</p>
|
|
<Button onClick={() => window.location.href = '/signin'}>
|
|
{tAuth('signIn')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<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">{t('title')}</h1>
|
|
<p className="text-muted-foreground">{t('subtitle')}</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-6 lg:gap-8">
|
|
{/* Avatar and Subscription Section */}
|
|
<div className="lg:col-span-1 space-y-6">
|
|
{/* Profile Picture */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<AvatarSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-left-2 duration-500">
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<h2 className="text-xl font-semibold text-foreground mb-4">{t('profilePicture')}</h2>
|
|
<div className="flex flex-col items-center">
|
|
<LegacyAvatar
|
|
src={user?.image || profile?.image}
|
|
alt="Profile Avatar"
|
|
size={96}
|
|
className="w-24 h-24"
|
|
/>
|
|
<p className="text-sm text-muted-foreground mt-2 text-center">
|
|
{user?.image ? t('googleAvatar') : t('defaultAvatar')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Subscription Status */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-left-2 duration-500 delay-100">
|
|
<div className="bg-card rounded-lg border border-border overflow-hidden transition-all duration-200 hover:shadow-sm">
|
|
<div className={`p-4 border-b border-border ${profile?.subscribePlan === 'pro'
|
|
? 'bg-gradient-to-r from-amber-50/60 to-orange-50/60 dark:from-amber-950/10 dark:to-orange-950/10'
|
|
: 'bg-gradient-to-r from-slate-50/60 to-gray-50/60 dark:from-slate-950/5 dark:to-gray-950/5'
|
|
}`}>
|
|
<div className="flex items-center space-x-3">
|
|
<div className={`p-2.5 rounded-full ${profile?.subscribePlan === 'pro'
|
|
? 'bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400'
|
|
: 'bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400'
|
|
}`}>
|
|
{profile?.subscribePlan === 'pro' ? (
|
|
<Crown className="w-4 h-4 text-white" />
|
|
) : (
|
|
<Star className="w-4 h-4 text-white" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-foreground text-sm sm:text-base">{t('currentPlan')}</h3>
|
|
<p className={`text-xs sm:text-sm truncate font-medium ${profile?.subscribePlan === 'pro'
|
|
? 'text-orange-700 dark:text-orange-300'
|
|
: 'text-slate-600 dark:text-slate-400'
|
|
}`}>
|
|
{profile?.subscribePlan === 'pro' ? t('proPlan') : t('freePlan')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
{/* Credit Balance */}
|
|
<div className="bg-gradient-to-r from-green-50/60 to-emerald-50/60 dark:from-green-950/8 dark:to-emerald-950/8 rounded-lg p-4 border border-green-200/30 dark:border-green-900/30">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="p-2 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 dark:from-green-400 dark:to-emerald-500">
|
|
<CreditCard className="w-4 h-4 text-white" />
|
|
</div>
|
|
<span className="text-sm font-semibold text-foreground">{t('creditBalance')}</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-green-700 dark:text-green-300">
|
|
${(creditInfo?.totalBalance || 0).toFixed(2)}
|
|
</div>
|
|
<div className="text-xs font-medium text-green-600 dark:text-green-400">{t('usdCredit')}</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 pt-3 border-t border-green-200/30 dark:border-green-900/30">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full text-xs border-green-200/50 dark:border-green-800/50 hover:bg-green-50 dark:hover:bg-green-900/20"
|
|
onClick={() => window.location.href = '/credits'}
|
|
>
|
|
{t('viewTransactionHistory')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Plan Details */}
|
|
<div className="space-y-2 text-sm">
|
|
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2">
|
|
<div className="flex justify-between py-1">
|
|
<span className="text-muted-foreground text-xs sm:text-sm">Max Prompts:</span>
|
|
<span className="font-medium text-foreground text-xs sm:text-sm">
|
|
{profile?.subscribePlan === 'pro' ? '500' : '20'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between py-1">
|
|
<span className="text-muted-foreground text-xs sm:text-sm">Max Versions:</span>
|
|
<span className="font-medium text-foreground text-xs sm:text-sm">
|
|
{profile?.maxVersionLimit}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between py-1 col-span-2 sm:col-span-1">
|
|
<span className="text-muted-foreground text-xs sm:text-sm">Monthly Credit:</span>
|
|
<span className="font-medium text-foreground text-xs sm:text-sm">
|
|
${profile?.subscribePlan === 'pro' ? '20.00' : '0.00'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Profile Information */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Username */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
|
<LoadingOverlay isLoading={fieldLoading.username}>
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-foreground">{t('username')}</h3>
|
|
{!isEditing.username && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(prev => ({ ...prev, username: true }))}
|
|
disabled={isLoading}
|
|
>
|
|
{tCommon('edit')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing.username ? (
|
|
<div className="space-y-4">
|
|
<Input
|
|
value={formData.username}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
|
placeholder={t('enterUsername')}
|
|
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" />
|
|
)}
|
|
{tCommon('save')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(prev => ({ ...prev, username: false }))
|
|
setFormData(prev => ({ ...prev, username: profile?.name || '' }))
|
|
}}
|
|
disabled={fieldLoading.username}
|
|
>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-foreground">{profile?.name || t('noUsernameSet')}</p>
|
|
)}
|
|
</div>
|
|
</LoadingOverlay>
|
|
</div>
|
|
)}
|
|
|
|
{/* Email */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-75">
|
|
<LoadingOverlay isLoading={fieldLoading.email}>
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-foreground">{t('email')}</h3>
|
|
{!isEditing.email && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(prev => ({ ...prev, email: true }))}
|
|
disabled={isLoading}
|
|
>
|
|
{tCommon('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={t('enterEmail')}
|
|
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" />
|
|
)}
|
|
{tCommon('save')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(prev => ({ ...prev, email: false }))
|
|
setFormData(prev => ({ ...prev, email: profile?.email || '' }))
|
|
}}
|
|
disabled={fieldLoading.email}
|
|
>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-foreground">{profile?.email}</p>
|
|
)}
|
|
</div>
|
|
</LoadingOverlay>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bio */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<TextAreaSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-150">
|
|
<LoadingOverlay isLoading={fieldLoading.bio}>
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-foreground">{t('bio')}</h3>
|
|
{!isEditing.bio && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(prev => ({ ...prev, bio: true }))}
|
|
disabled={isLoading}
|
|
>
|
|
{tCommon('edit')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing.bio ? (
|
|
<div className="space-y-4">
|
|
<Textarea
|
|
value={formData.bio}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
|
|
placeholder={t('tellUsAboutYourself')}
|
|
className="min-h-[100px]"
|
|
maxLength={500}
|
|
disabled={fieldLoading.bio}
|
|
/>
|
|
<div className="text-sm text-muted-foreground text-right">
|
|
{formData.bio.length}/500 {t('charactersLimit')}
|
|
</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" />
|
|
)}
|
|
{tCommon('save')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(prev => ({ ...prev, bio: false }))
|
|
setFormData(prev => ({ ...prev, bio: profile?.bio || '' }))
|
|
}}
|
|
disabled={fieldLoading.bio}
|
|
>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-foreground">{profile?.bio || t('noBioAdded')}</p>
|
|
)}
|
|
</div>
|
|
</LoadingOverlay>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* Password */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-200">
|
|
<LoadingOverlay isLoading={fieldLoading.password}>
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-foreground">{tAuth('password')}</h3>
|
|
{!isEditing.password && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(prev => ({ ...prev, password: true }))}
|
|
disabled={isLoading}
|
|
>
|
|
{t('changePassword')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing.password ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="newPassword">{t('newPassword')}</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={t('enterNewPassword')}
|
|
className="pr-10"
|
|
disabled={fieldLoading.password}
|
|
/>
|
|
<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"
|
|
disabled={fieldLoading.password}
|
|
>
|
|
{showPasswords.new ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="confirmPassword">{t('confirmNewPassword')}</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={t('confirmNewPassword')}
|
|
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" />
|
|
)}
|
|
{t('updatePassword')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(prev => ({ ...prev, password: false }))
|
|
setFormData(prev => ({
|
|
...prev,
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
}))
|
|
}}
|
|
disabled={fieldLoading.password}
|
|
>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground">••••••••</p>
|
|
)}
|
|
</div>
|
|
</LoadingOverlay>
|
|
</div>
|
|
)}
|
|
|
|
{/* Version Limit Settings */}
|
|
{profileLoading ? (
|
|
<div className="animate-in fade-in-0 duration-300">
|
|
<FormFieldSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-300">
|
|
<LoadingOverlay isLoading={fieldLoading.versionLimit}>
|
|
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-foreground">Version Limit</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Maximum number of versions to keep for each prompt
|
|
</p>
|
|
</div>
|
|
{!isEditing.versionLimit && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(prev => ({ ...prev, versionLimit: true }))}
|
|
disabled={isLoading}
|
|
>
|
|
{tCommon('edit')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing.versionLimit ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center space-x-4">
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max={profile?.maxVersionLimit || 3}
|
|
value={formData.versionLimit}
|
|
onChange={(e) => setFormData(prev => ({
|
|
...prev,
|
|
versionLimit: Math.max(1, Math.min(parseInt(e.target.value) || 1, profile?.maxVersionLimit || 3))
|
|
}))}
|
|
className="w-24"
|
|
disabled={fieldLoading.versionLimit}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">
|
|
versions (max: {profile?.maxVersionLimit || 3} for {profile?.subscribePlan || 'free'} plan)
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
When this limit is reached, oldest versions will be automatically deleted when new versions are created.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => updateProfile('versionLimit', formData.versionLimit)}
|
|
disabled={isLoading || fieldLoading.versionLimit}
|
|
>
|
|
{fieldLoading.versionLimit ? (
|
|
<LoadingSpinner size="sm" className="mr-2" />
|
|
) : (
|
|
<Save className="w-4 h-4 mr-2" />
|
|
)}
|
|
{tCommon('save')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(prev => ({ ...prev, versionLimit: false }))
|
|
setFormData(prev => ({ ...prev, versionLimit: profile?.versionLimit || 3 }))
|
|
}}
|
|
disabled={fieldLoading.versionLimit}
|
|
>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-foreground">
|
|
Current limit: <span className="font-medium">{profile?.versionLimit || 3} versions</span>
|
|
</p>
|
|
<div className="text-sm text-muted-foreground">
|
|
<p>Subscription: <span className="capitalize">{profile?.subscribePlan || 'free'}</span></p>
|
|
<p>Maximum allowed: {profile?.maxVersionLimit || 3} versions</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</LoadingOverlay>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |