Some checks failed
continuous-integration/drone/pr Build is failing
- Обновлен LayoutWrapper с улучшенным UI навигации - Добавлен dropdown меню пользователя с аватаром - Интегрированы ThemeToggle и LanguageSelector в навигацию - Переключатели темы и языка теперь всегда видны - Добавлены флаги стран в селектор языков - Создана страница редактирования профиля /profile - Улучшены стили для темной темы в navbar - Добавлены CSS стили для навигации и профиля
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useLocale } from '../contexts/LocaleContext'
|
|
|
|
interface UserProfile {
|
|
id: number
|
|
username: string
|
|
email: string
|
|
full_name: string
|
|
bio: string
|
|
avatar: string | null
|
|
cover: string | null
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const { t } = useLocale()
|
|
const router = useRouter()
|
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
|
const [coverFile, setCoverFile] = useState<File | null>(null)
|
|
|
|
useEffect(() => {
|
|
loadProfile()
|
|
}, [])
|
|
|
|
const loadProfile = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token')
|
|
if (!token) {
|
|
router.push('/auth/login')
|
|
return
|
|
}
|
|
|
|
const response = await fetch('/api/auth/user', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load profile')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setProfile(data)
|
|
} catch (error) {
|
|
console.error('Error loading profile:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!profile) return
|
|
|
|
setSaving(true)
|
|
try {
|
|
const token = localStorage.getItem('token')
|
|
const formData = new FormData()
|
|
|
|
formData.append('username', profile.username)
|
|
formData.append('email', profile.email)
|
|
formData.append('full_name', profile.full_name)
|
|
formData.append('bio', profile.bio)
|
|
|
|
if (avatarFile) {
|
|
formData.append('avatar', avatarFile)
|
|
}
|
|
|
|
if (coverFile) {
|
|
formData.append('cover', coverFile)
|
|
}
|
|
|
|
const response = await fetch('/api/auth/user', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: formData
|
|
})
|
|
|
|
if (response.ok) {
|
|
const updatedProfile = await response.json()
|
|
setProfile(updatedProfile)
|
|
// Очистить выбранные файлы
|
|
setAvatarFile(null)
|
|
setCoverFile(null)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving profile:', error)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const removeAvatar = () => {
|
|
if (profile) {
|
|
setProfile({ ...profile, avatar: null })
|
|
}
|
|
}
|
|
|
|
const removeCover = () => {
|
|
if (profile) {
|
|
setProfile({ ...profile, cover: null })
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="d-flex justify-content-center">
|
|
<div className="spinner-border" role="status">
|
|
<span className="visually-hidden">{t('common.loading')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!profile) {
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="alert alert-danger">
|
|
{t('common.error')}: Profile not found
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="row justify-content-center">
|
|
<div className="col-md-8">
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h4 className="mb-0">
|
|
<i className="fas fa-user me-2"></i>
|
|
{t('profile.edit')}
|
|
</h4>
|
|
</div>
|
|
<div className="card-body">
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Avatar */}
|
|
<div className="mb-4 text-center">
|
|
<div className="mb-3">
|
|
<img
|
|
src={
|
|
avatarFile
|
|
? URL.createObjectURL(avatarFile)
|
|
: profile.avatar
|
|
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.avatar}`
|
|
: '/assets/img/avatar-dhg.png'
|
|
}
|
|
alt="Avatar"
|
|
className="rounded-circle profile-avatar"
|
|
width={120}
|
|
height={120}
|
|
/>
|
|
</div>
|
|
<div className="d-flex gap-2 justify-content-center">
|
|
<label className="btn btn-outline-primary btn-sm">
|
|
<i className="fas fa-camera me-1"></i>
|
|
{t('profile.avatar')}
|
|
<input
|
|
type="file"
|
|
className="d-none"
|
|
accept="image/*"
|
|
onChange={(e) => setAvatarFile(e.target.files?.[0] || null)}
|
|
/>
|
|
</label>
|
|
{(profile.avatar || avatarFile) && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-danger btn-sm"
|
|
onClick={removeAvatar}
|
|
>
|
|
<i className="fas fa-times me-1"></i>
|
|
{t('profile.removeAvatar')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cover Image */}
|
|
<div className="mb-4">
|
|
<label className="form-label">{t('profile.cover')}</label>
|
|
{(profile.cover || coverFile) && (
|
|
<div className="mb-2">
|
|
<img
|
|
src={
|
|
coverFile
|
|
? URL.createObjectURL(coverFile)
|
|
: `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.cover}`
|
|
}
|
|
alt="Cover"
|
|
className="img-fluid rounded profile-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="d-flex gap-2">
|
|
<label className="btn btn-outline-primary btn-sm">
|
|
<i className="fas fa-image me-1"></i>
|
|
{profile.cover ? t('common.update') : t('common.add')}
|
|
<input
|
|
type="file"
|
|
className="d-none"
|
|
accept="image/*"
|
|
onChange={(e) => setCoverFile(e.target.files?.[0] || null)}
|
|
/>
|
|
</label>
|
|
{(profile.cover || coverFile) && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-danger btn-sm"
|
|
onClick={removeCover}
|
|
>
|
|
<i className="fas fa-times me-1"></i>
|
|
{t('profile.removeCover')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Basic Info */}
|
|
<div className="row">
|
|
<div className="col-md-6">
|
|
<div className="mb-3">
|
|
<label className="form-label">{t('profile.username')}</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={profile.username}
|
|
onChange={(e) => setProfile({ ...profile, username: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<div className="mb-3">
|
|
<label className="form-label">{t('profile.email')}</label>
|
|
<input
|
|
type="email"
|
|
className="form-control"
|
|
value={profile.email}
|
|
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<label className="form-label">{t('profile.fullName')}</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={profile.full_name}
|
|
onChange={(e) => setProfile({ ...profile, full_name: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="form-label">{t('profile.bio')}</label>
|
|
<textarea
|
|
className="form-control"
|
|
rows={4}
|
|
value={profile.bio}
|
|
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
|
|
placeholder={t('profile.bio')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="d-flex justify-content-between">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => router.back()}
|
|
>
|
|
<i className="fas fa-arrow-left me-2"></i>
|
|
{t('common.back')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={saving}
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
{t('common.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="fas fa-save me-2"></i>
|
|
{t('common.save')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |