feat: улучшена навигационная панель с полной интеграцией темы и локализации
Some checks failed
continuous-integration/drone/pr Build is failing
Some checks failed
continuous-integration/drone/pr Build is failing
- Обновлен LayoutWrapper с улучшенным UI навигации - Добавлен dropdown меню пользователя с аватаром - Интегрированы ThemeToggle и LanguageSelector в навигацию - Переключатели темы и языка теперь всегда видны - Добавлены флаги стран в селектор языков - Создана страница редактирования профиля /profile - Улучшены стили для темной темы в navbar - Добавлены CSS стили для навигации и профиля
This commit is contained in:
311
frontend/linktree-frontend/src/app/profile/page.tsx
Normal file
311
frontend/linktree-frontend/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user