dramaling-vocab-learning/frontend/app/profile/page.tsx

684 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { API_URLS } from '@/lib/config/api'
import { Navigation } from '@/components/shared/Navigation'
import { useAuth } from '@/contexts/AuthContext'
interface UserProfile {
id: string
email: string
displayName: string | null
avatarUrl: string | null
subscriptionType: string
createdAt: string
}
interface UserSettings {
dailyGoal: number
reminderTime: string
reminderEnabled: boolean
difficultyPreference: string
autoPlayAudio: boolean
showPronunciation: boolean
}
interface LanguageLevel {
value: string
label: string
description: string
examples: string[]
}
type TabType = 'profile' | 'settings' | 'level'
export default function ProfilePage() {
const { updateUser } = useAuth()
const [activeTab, setActiveTab] = useState<TabType>('profile')
const [profile, setProfile] = useState<UserProfile | null>(null)
const [settings, setSettings] = useState<UserSettings | null>(null)
const [userLevel, setUserLevel] = useState('A2')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
// 編輯表單狀態
const [editForm, setEditForm] = useState({
displayName: '',
dailyGoal: 20,
reminderEnabled: true,
difficultyPreference: 'balanced',
autoPlayAudio: true,
showPronunciation: true
})
// 英語程度定義
const levels: LanguageLevel[] = [
{
value: 'A1',
label: 'A1 - 初學者',
description: '能理解基本詞彙和簡單句子',
examples: ['hello', 'good', 'house', 'eat', 'happy']
},
{
value: 'A2',
label: 'A2 - 基礎',
description: '能處理日常對話和常見主題',
examples: ['important', 'difficult', 'interesting', 'beautiful', 'understand']
},
{
value: 'B1',
label: 'B1 - 中級',
description: '能理解清楚標準語言的要點',
examples: ['analyze', 'opportunity', 'environment', 'responsibility', 'development']
},
{
value: 'B2',
label: 'B2 - 中高級',
description: '能理解複雜文本的主要內容',
examples: ['sophisticated', 'implications', 'comprehensive', 'substantial', 'methodology']
},
{
value: 'C1',
label: 'C1 - 高級',
description: '能流利表達,理解含蓄意思',
examples: ['meticulous', 'predominantly', 'intricate', 'corroborate', 'paradigm']
},
{
value: 'C2',
label: 'C2 - 精通',
description: '接近母語水平',
examples: ['ubiquitous', 'ephemeral', 'perspicacious', 'multifarious', 'idiosyncratic']
}
]
const tabs = [
{ id: 'profile' as TabType, label: '個人資料', icon: '👤' },
// { id: 'settings' as TabType, label: '學習設定', icon: '⚙️' },
{ id: 'level' as TabType, label: '英語程度', icon: '🎯' }
]
// 載入資料
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setIsLoading(true)
setError(null)
try {
const token = localStorage.getItem('auth_token')
if (!token) {
setError('請先登入')
return
}
console.log('🚀 載入個人檔案資料...')
// 並行載入所有資料
const [profileResponse, settingsResponse] = await Promise.all([
fetch(API_URLS.auth('/profile'), {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}),
fetch(API_URLS.auth('/settings'), {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
})
])
if (!profileResponse.ok || !settingsResponse.ok) {
if (profileResponse.status === 401 || settingsResponse.status === 401) {
localStorage.removeItem('auth_token')
setError('登入已過期,請重新登入')
} else {
setError('載入個人資料失敗')
}
return
}
const [profileData, settingsData] = await Promise.all([
profileResponse.json(),
settingsResponse.json()
])
if (profileData.success && settingsData.success) {
setProfile(profileData.data)
setSettings(settingsData.data)
// 初始化編輯表單
setEditForm({
displayName: profileData.data.displayName || '',
dailyGoal: settingsData.data.dailyGoal || 20,
reminderEnabled: settingsData.data.reminderEnabled ?? true,
difficultyPreference: settingsData.data.difficultyPreference || 'balanced',
autoPlayAudio: settingsData.data.autoPlayAudio ?? true,
showPronunciation: settingsData.data.showPronunciation ?? true
})
console.log('✅ 個人檔案資料載入成功')
} else {
setError('載入資料失敗')
}
// 載入英語程度
const savedLevel = localStorage.getItem('userEnglishLevel')
if (savedLevel) {
setUserLevel(savedLevel)
}
} catch (error) {
console.error('載入個人資料錯誤:', error)
setError(error instanceof Error ? error.message : '載入失敗')
} finally {
setIsLoading(false)
}
}
const handleSaveProfile = async () => {
if (!profile) return
setIsSaving(true)
try {
const token = localStorage.getItem('auth_token')
if (!token) {
setError('請先登入')
return
}
const response = await fetch(API_URLS.auth('/profile'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: editForm.displayName })
})
if (response.ok) {
await loadData() // 重新載入資料
// 同步更新全域 AuthContext 中的用戶資料
updateUser({ displayName: editForm.displayName })
console.log('✅ 個人資料更新成功')
} else {
setError('儲存失敗')
}
} catch (error) {
setError('儲存失敗')
} finally {
setIsSaving(false)
}
}
const handleSaveSettings = async () => {
setIsSaving(true)
try {
const token = localStorage.getItem('auth_token')
if (!token) {
setError('請先登入')
return
}
const response = await fetch(API_URLS.auth('/settings'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
dailyGoal: editForm.dailyGoal,
reminderEnabled: editForm.reminderEnabled,
difficultyPreference: editForm.difficultyPreference,
autoPlayAudio: editForm.autoPlayAudio,
showPronunciation: editForm.showPronunciation
})
})
if (response.ok) {
await loadData() // 重新載入資料
console.log('✅ 學習設定更新成功')
} else {
setError('儲存失敗')
}
} catch (error) {
setError('儲存失敗')
} finally {
setIsSaving(false)
}
}
const handleSaveLevel = () => {
localStorage.setItem('userEnglishLevel', userLevel)
console.log('✅ 英語程度已保存:', userLevel)
}
const handleLogout = () => {
localStorage.removeItem('auth_token')
localStorage.removeItem('userEnglishLevel')
window.location.href = '/login'
}
// 載入狀態
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<h2 className="text-xl font-semibold text-gray-700">...</h2>
</div>
</div>
</div>
</div>
)
}
// 錯誤狀態
if (error) {
const isAuthError = error.includes('登入') || error.includes('認證')
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<div className={`text-4xl mb-4 ${isAuthError ? 'text-yellow-500' : 'text-red-500'}`}>
{isAuthError ? '🔒' : '⚠️'}
</div>
<h2 className={`text-xl font-semibold mb-2 ${isAuthError ? 'text-yellow-700' : 'text-red-700'}`}>
{isAuthError ? '需要重新登入' : '載入失敗'}
</h2>
<p className="text-gray-600 mb-6">{error}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{isAuthError ? (
<button
onClick={() => window.location.href = '/login'}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold"
>
</button>
) : (
<button
onClick={loadData}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
)}
<button
onClick={() => window.location.href = '/'}
className="px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}
if (!profile || !settings) {
return null
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* 頁面標題 */}
<div className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
{/* 分頁導航 */}
<div className="mb-8">
<div className="border-b border-gray-200 bg-white rounded-t-xl">
<nav className="flex space-x-8 px-6 py-4">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center space-x-2 py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</nav>
</div>
</div>
{/* 分頁內容 */}
<div className="bg-white rounded-xl rounded-t-none shadow-lg">
{/* 個人資料分頁 */}
{activeTab === 'profile' && (
<div className="p-8">
<div className="max-w-2xl mx-auto">
{/* 用戶資訊卡片 */}
<div className="text-center mb-8">
<div className="w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt="用戶頭像"
className="w-full h-full rounded-full object-cover"
/>
) : (
<span className="text-3xl text-white">
{(profile.displayName || profile.email)[0].toUpperCase()}
</span>
)}
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-1">
{profile.displayName || '用戶'}
</h2>
<p className="text-gray-500 mb-3">{profile.email}</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
profile.subscriptionType === 'premium'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}>
{profile.subscriptionType === 'premium' ? '🌟 Premium' : '🆓 免費版'}
</span>
</div>
{/* 編輯資料 */}
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex space-x-3">
<input
type="text"
value={editForm.displayName}
onChange={(e) => setEditForm(prev => ({ ...prev, displayName: e.target.value }))}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="請輸入顯示名稱"
/>
<button
onClick={handleSaveProfile}
disabled={isSaving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{isSaving ? '儲存中...' : '儲存'}
</button>
</div>
</div>
{/* 帳戶資訊 */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600"> ID</span>
<p className="font-mono text-gray-800 text-xs break-all">{profile.id}</p>
</div>
<div>
<span className="text-gray-600"></span>
<p className="font-medium text-gray-800">
{new Date(profile.createdAt).toLocaleDateString('zh-TW')}
</p>
</div>
</div>
</div>
{/* 登出 */}
<div className="border-t pt-6">
<button
onClick={handleLogout}
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium"
>
</button>
</div>
</div>
</div>
</div>
)}
{/* 學習設定分頁 */}
{activeTab === 'settings' && (
<div className="p-8">
<div className="max-w-2xl mx-auto">
<div className="space-y-6">
{/* 每日目標 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
</label>
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-2xl font-bold text-blue-600">{editForm.dailyGoal}</span>
<span className="text-sm text-gray-600">/</span>
</div>
<input
type="range"
min="1"
max="100"
value={editForm.dailyGoal}
onChange={(e) => setEditForm(prev => ({ ...prev, dailyGoal: Number(e.target.value) }))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>1</span>
<span>50</span>
<span>100</span>
</div>
</div>
</div>
{/* 學習偏好 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{ value: 'conservative', label: '保守', desc: '較簡單', color: 'green' },
{ value: 'balanced', label: '均衡', desc: '適中', color: 'blue' },
{ value: 'aggressive', label: '積極', desc: '較難', color: 'purple' }
].map(option => (
<label
key={option.value}
className={`p-3 border-2 rounded-lg cursor-pointer transition-all ${
editForm.difficultyPreference === option.value
? `border-${option.color}-500 bg-${option.color}-50`
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="difficulty"
value={option.value}
checked={editForm.difficultyPreference === option.value}
onChange={(e) => setEditForm(prev => ({ ...prev, difficultyPreference: e.target.value }))}
className="sr-only"
/>
<div className="text-center">
<div className="font-semibold text-gray-900">{option.label}</div>
<div className="text-sm text-gray-600">{option.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* 音頻和顯示設定 */}
<div className="space-y-4">
<h3 className="font-medium text-gray-900"></h3>
{[
{
key: 'reminderEnabled' as keyof typeof editForm,
label: '複習提醒',
desc: '接收學習提醒通知'
},
{
key: 'autoPlayAudio' as keyof typeof editForm,
label: '自動播放音頻',
desc: '顯示詞卡時自動播放發音'
},
{
key: 'showPronunciation' as keyof typeof editForm,
label: '顯示發音標示',
desc: '在詞卡上顯示音標'
}
].map(setting => (
<div key={setting.key} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<span className="font-medium text-gray-900">{setting.label}</span>
<p className="text-sm text-gray-600">{setting.desc}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={editForm[setting.key] as boolean}
onChange={(e) => setEditForm(prev => ({ ...prev, [setting.key]: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
))}
</div>
{/* 儲存按鈕 */}
<div className="pt-4">
<button
onClick={handleSaveSettings}
disabled={isSaving}
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50"
>
{isSaving ? '儲存中...' : '儲存學習設定'}
</button>
</div>
</div>
</div>
</div>
)}
{/* 英語程度分頁 */}
{activeTab === 'level' && (
<div className="p-8">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-semibold text-gray-900 mb-2">🎯 </h2>
<p className="text-gray-600">
</p>
</div>
{/* 程度選擇 */}
<div className="space-y-4 mb-8">
{levels.map(level => (
<label
key={level.value}
className={`block p-6 border-2 rounded-xl cursor-pointer transition-all hover:shadow-md ${
userLevel === level.value
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="level"
value={level.value}
checked={userLevel === level.value}
onChange={(e) => setUserLevel(e.target.value)}
className="sr-only"
/>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-bold text-xl text-gray-800 mb-2">
{level.label}
</div>
<div className="text-gray-600 mb-3">
{level.description}
</div>
<div className="flex flex-wrap gap-2">
{level.examples.map(example => (
<span
key={example}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
{example}
</span>
))}
</div>
</div>
{userLevel === level.value && (
<div className="ml-4">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
</div>
)}
</div>
</label>
))}
</div>
{/* 學習效果預覽 */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-xl mb-6">
<h3 className="font-bold text-lg text-blue-800 mb-3">
💡
</h3>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h4 className="font-semibold text-blue-700 mb-2"></h4>
<p className="text-blue-600">
({userLevel})1-2
</p>
</div>
<div>
<h4 className="font-semibold text-blue-700 mb-2"></h4>
<p className="text-blue-600 text-sm">
</p>
</div>
</div>
</div>
{/* 儲存按鈕 */}
<button
onClick={handleSaveLevel}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-lg"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}