Reflex/src/views/ProfileView.vue

2515 lines
71 KiB
Vue
Raw 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.

<template>
<div class="app-profile-view">
<!-- Loading State -->
<div v-if="loading && initialLoading" class="loading-section">
<div class="loading-spinner">
<div class="spinner"></div>
<p>Загрузка профиля...</p>
</div>
</div>
<!-- Error State -->
<div v-if="error" class="error-section">
<div class="container">
<div class="error-card">
<i class="bi-exclamation-triangle"></i>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="retry-btn" @click="fetchProfileDataLocal">Попробовать снова</button>
</div>
</div>
</div>
<!-- Main Content -->
<div v-if="profileData && !initialLoading" class="profile-content">
<div class="container">
<div class="profile-layout">
<!-- Profile Header Card -->
<div class="profile-header-card">
<div class="profile-header-content">
<div class="avatar-section">
<div class="avatar-container">
<img
v-if="mainPhoto"
:src="mainPhoto.url"
:alt="profileData.name"
class="avatar-image"
>
<div v-else class="avatar-placeholder">
<i class="bi-person"></i>
</div>
<div class="avatar-overlay" v-if="isEditMode">
<button class="change-avatar-btn" @click="scrollToPhotos">
<i class="bi-camera"></i>
</button>
</div>
</div>
<div class="user-meta">
<h2 class="user-name">{{ profileData.name || 'Не указано' }}</h2>
<p class="user-email">{{ profileData.email }}</p>
<div class="user-badges">
<span class="badge verified">Подтвержден</span>
<span class="badge member-since">С {{ formatShortDate(profileData.createdAt) }}</span>
</div>
</div>
<div class="header-actions">
<button
class="edit-btn"
@click="toggleEditMode"
:disabled="profileLoading"
>
<i v-if="!isEditMode" class="bi-pencil"></i>
<i v-else class="bi-check"></i>
{{ isEditMode ? 'Сохранить' : 'Редактировать' }}
</button>
</div>
</div>
<!-- Stats Section -->
<div class="stats-section">
<div class="stat-item">
<div class="stat-icon">
<i class="bi-images"></i>
</div>
<div class="stat-info">
<span class="stat-value">{{ profileData.photos?.length || 0 }}</span>
<span class="stat-label">Фото</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">
<i class="bi-heart"></i>
</div>
<div class="stat-info">
<span class="stat-value">{{ profileData.matches?.length || 0 }}</span>
<span class="stat-label">Совпадения</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">
<i class="bi-check-circle"></i>
</div>
<div class="stat-info">
<span class="stat-value">{{ getProfileCompletion() }}%</span>
<span class="stat-label">Заполнено</span>
</div>
</div>
</div>
</div>
</div>
<!-- Content Tabs -->
<div class="profile-tabs">
<div class="tabs-container">
<button class="tab-btn" :class="{ 'active': activeTab === 'info' }" @click="activeTab = 'info'">
<i class="bi-person-lines-fill"></i>
<span>Информация</span>
</button>
<button class="tab-btn" :class="{ 'active': activeTab === 'photos' }" @click="activeTab = 'photos'">
<i class="bi-images"></i>
<span>Фотографии</span>
<span class="tab-badge" v-if="profileData.photos?.length">{{ profileData.photos.length }}</span>
</button>
</div>
</div>
<!-- Tabs Content -->
<div class="tabs-content">
<!-- Basic Info Tab -->
<div v-show="activeTab === 'info'" class="tab-pane">
<div class="info-card basic-info-card">
<div class="card-header">
<h3><i class="bi-person-lines-fill"></i> Основная информация</h3>
</div>
<div class="card-content">
<!-- Форма с сообщениями -->
<div v-if="profileActionError" class="alert error">
<i class="bi-exclamation-circle"></i>
{{ profileActionError }}
</div>
<div v-if="profileActionSuccess" class="alert success">
<i class="bi-check-circle"></i>
{{ profileActionSuccess }}
</div>
<!-- Поля данных -->
<div class="info-grid">
<div class="info-item">
<label for="editName">Имя</label>
<span v-if="!isEditMode">{{ profileData.name || 'Не указано' }}</span>
<input
v-else
type="text"
class="form-input"
id="editName"
v-model="editableProfileData.name"
:disabled="profileLoading"
placeholder="Введите ваше имя"
/>
</div>
<div class="info-item">
<label for="editDateOfBirth">Дата рождения</label>
<span v-if="!isEditMode">{{ profileData.dateOfBirth ? formatDate(profileData.dateOfBirth) : 'Не указана' }}</span>
<input
v-else
type="date"
class="form-input"
id="editDateOfBirth"
v-model="editableProfileData.dateOfBirth"
:disabled="profileLoading"
onfocus="this.showPicker()"
/>
</div>
<div class="info-item">
<label for="editGender">Пол</label>
<span v-if="!isEditMode">{{ getGenderText(profileData.gender) }}</span>
<div v-else class="select-wrapper">
<input
type="text"
class="form-input"
id="editGender"
v-model="genderSearchQuery"
placeholder="Выберите пол..."
@focus="showGenderList = true; filteredGenders = genderOptions"
@input="onGenderSearch"
:disabled="profileLoading"
/>
<button
type="button"
class="clear-btn"
@click="clearGenderSelection"
v-if="editableProfileData.gender"
:disabled="profileLoading"
>
<i class="bi-x"></i>
</button>
<div class="dropdown" v-if="showGenderList && filteredGenders.length > 0">
<div
v-for="option in filteredGenders"
:key="option.value"
@click="selectGender(option)"
class="dropdown-option"
>
{{ option.text }}
</div>
</div>
</div>
</div>
<div class="info-item">
<label for="editCity">Город</label>
<span v-if="!isEditMode">{{ profileData.location?.city || 'Не указан' }}</span>
<div v-else class="select-wrapper">
<input
type="text"
class="form-input"
id="editCity"
v-model="citySearchQuery"
placeholder="Начните вводить название города..."
@focus="showCityList = true"
@input="onCitySearch"
:disabled="profileLoading"
/>
<button
type="button"
class="clear-btn"
@click="clearCitySelection"
v-if="editableProfileData.location && editableProfileData.location.city"
:disabled="profileLoading"
>
<i class="bi-x"></i>
</button>
<div class="dropdown" v-if="showCityList && filteredCities.length > 0">
<div
v-for="city in filteredCities"
:key="city"
class="dropdown-option"
@click="selectCity(city)"
>
{{ city }}
</div>
</div>
</div>
</div>
<div class="info-item full-width">
<label for="editBio">О себе</label>
<span v-if="!isEditMode" class="bio-text">{{ profileData.bio || 'Расскажите о себе...' }}</span>
<textarea
v-else
class="form-input form-textarea"
id="editBio"
rows="4"
v-model="editableProfileData.bio"
:disabled="profileLoading"
placeholder="Расскажите немного о себе..."
></textarea>
</div>
</div>
<!-- Секция выхода из аккаунта -->
<div class="logout-section">
<button
class="logout-btn"
@click="logoutUser"
title="Выйти из аккаунта"
>
<i class="bi-box-arrow-right"></i>
Выйти из аккаунта
</button>
</div>
</div>
</div>
</div>
<!-- Photos Tab -->
<div v-show="activeTab === 'photos'" class="tab-pane" id="photos-section">
<div class="info-card photos-card">
<div class="card-header">
<h3><i class="bi-images"></i> Мои фотографии</h3>
<div class="header-actions" v-if="profileData.photos && profileData.photos.length > 0">
<button class="add-photo-btn" @click="triggerPhotoUpload" :disabled="photoActionLoading">
<span v-if="photoActionLoading" class="spinner-small"></span>
<i v-else class="bi-plus"></i>
{{ photoActionLoading ? 'Загрузка...' : 'Добавить фото' }}
</button>
</div>
</div>
<div class="card-content">
<!-- Photo Grid -->
<div v-if="profileData.photos && profileData.photos.length > 0" class="photo-grid">
<div
v-for="photo in profileData.photos"
:key="photo.public_id || photo._id"
class="photo-item"
:class="{ 'main-photo': photo.isProfilePhoto }"
>
<img :src="photo.url" :alt="'Фото ' + profileData.name" class="photo-image">
<div v-if="photo.isProfilePhoto" class="main-badge">
<i class="bi-star-fill"></i>
Главное
</div>
<div class="photo-actions">
<button
v-if="!photo.isProfilePhoto"
class="action-btn small primary"
@click="setAsMainPhoto(photo._id)"
:disabled="photoActionLoading"
title="Сделать главным"
>
<i class="bi-star"></i>
</button>
<button
class="action-btn small danger"
@click="confirmDeletePhoto(photo._id)"
:disabled="photoActionLoading"
title="Удалить"
>
<i class="bi-trash"></i>
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-photos">
<div class="upload-zone" @click="triggerPhotoUpload" :class="{ 'uploading': photoActionLoading }">
<div class="upload-icon-container">
<div class="upload-icon-bg">
<i v-if="!photoActionLoading" class="bi-camera-fill upload-icon"></i>
<div v-else class="upload-spinner"></div>
</div>
<div class="upload-pulse"></div>
</div>
<div class="upload-content">
<h3 class="upload-title">
{{ photoActionLoading ? 'Загружаем...' : 'Добавьте первое фото' }}
</h3>
<p class="upload-description">
{{ photoActionLoading ? 'Подождите, фотография обрабатывается' : 'Нажмите или перетащите файлы сюда для загрузки' }}
</p>
<div class="upload-features">
<div class="feature-item">
<i class="bi-shield-check"></i>
<span>Безопасно</span>
</div>
<div class="feature-item">
<i class="bi-lightning-charge"></i>
<span>Быстро</span>
</div>
<div class="feature-item">
<i class="bi-image"></i>
<span>HD качество</span>
</div>
</div>
<div class="upload-btn-container">
<button
class="upload-btn"
@click.stop="triggerPhotoUpload"
:disabled="photoActionLoading"
>
<span v-if="photoActionLoading" class="btn-spinner"></span>
<i v-else class="bi-plus-circle-fill"></i>
{{ photoActionLoading ? 'Загрузка...' : 'Выбрать фото' }}
</button>
<div class="upload-hint">
<small>Поддерживаются: JPG, PNG, WebP до 10 МБ</small>
</div>
</div>
</div>
</div>
</div>
<!-- Photo Messages -->
<div v-if="photoActionError" class="alert error">
<i class="bi-exclamation-circle"></i>
{{ photoActionError }}
</div>
<div v-if="photoActionSuccess" class="alert success">
<i class="bi-check-circle"></i>
{{ photoActionSuccess }}
</div>
<div v-if="photoActionLoading" class="alert info">
<div class="spinner-small" style="border-top-color:#667eea"></div>
Загрузка фотографий...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden File Input -->
<input
ref="fileInput"
type="file"
multiple
accept="image/*"
@change="handlePhotoSelect"
style="display: none;"
>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" v-if="showDeleteModal" @click="closeDeleteModal">
<div class="delete-modal" @click.stop>
<div class="modal-header">
<h3>Удалить фотографию</h3>
<button class="close-btn" @click="closeDeleteModal">
<i class="bi-x"></i>
</button>
</div>
<div class="modal-content">
<p>Вы уверены, что хотите удалить эту фотографию? Это действие нельзя отменить.</p>
</div>
<div class="modal-actions">
<button class="action-btn secondary" @click="closeDeleteModal">Отмена</button>
<button
class="action-btn danger"
@click="executeDeletePhoto"
:disabled="photoActionLoading"
>
<span v-if="photoActionLoading" class="spinner-small"></span>
Удалить
</button>
</div>
</div>
</div>
<!-- No Data State -->
<div v-if="!profileData && !initialLoading && !error" class="no-data-section">
<div class="container">
<div class="no-data-card">
<i class="bi-person-x"></i>
<h3>Данные профиля недоступны</h3>
<p>Возможно, вы не авторизованы или произошла ошибка.</p>
<router-link to="/login" class="action-btn primary">Войти в систему</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue';
import { useAuth } from '@/auth';
import api from '@/services/api';
const { isAuthenticated, user: authUserFromStore, token, fetchUser, logout } = useAuth();
const profileData = ref(null);
const loading = ref(true);
const initialLoading = ref(true);
const error = ref('');
const isEditMode = ref(false);
const activeTab = ref('info'); // Добавляем переключатель вкладок
let originalProfileData = ''; // Для хранения исходных данных профиля в формате JSON
// Для управления фото
const photoActionLoading = ref(false);
const photoActionError = ref('');
const photoActionSuccess = ref('');
const photoToDeleteId = ref(null);
const showDeleteModal = ref(false);
const fileInput = ref(null);
// Для редактирования профиля
const editableProfileData = ref({
name: '',
bio: '',
dateOfBirth: '',
gender: '',
location: { city: '' }
});
const profileLoading = ref(false);
const profileActionError = ref('');
const profileActionSuccess = ref('');
const citySearchQuery = ref('');
const showCityList = ref(false);
const filteredCities = ref([]);
let cities = []; // для хранения списка городов
// Для выпадающего списка пола
const genderOptions = ref([
{ value: 'male', text: 'Мужской' },
{ value: 'female', text: 'Женский' },
{ value: 'other', text: 'Другой' }
]);
const genderSearchQuery = ref('');
const showGenderList = ref(false);
const filteredGenders = ref([]);
// Computed properties
const mainPhoto = computed(() => {
return profileData.value?.photos?.find(photo => photo.isProfilePhoto) ||
profileData.value?.photos?.[0] || null;
});
// Methods
const toggleEditMode = async () => {
// Если сейчас в режиме редактирования и пользователь хочет вернуться в режим просмотра
if (isEditMode.value) {
try {
// Проверяем, были ли изменения
const hasChanges = checkForChanges();
console.log('[ProfileView] Обнаружены изменения:', hasChanges);
if (hasChanges) {
console.log('[ProfileView] Сохранение изменений...');
profileLoading.value = true; // Устанавливаем индикатор загрузки формы
// Подготавливаем данные для отправки на сервер
const dataToUpdate = { ...editableProfileData.value };
// Обрабатываем дату рождения
if (dataToUpdate.dateOfBirth) {
try {
const dateObj = new Date(dataToUpdate.dateOfBirth);
if (!isNaN(dateObj.getTime())) {
dataToUpdate.dateOfBirth = dateObj.toISOString();
}
} catch (e) {
console.error('[ProfileView] Ошибка обработки даты:', e);
}
}
// Отправляем запрос на сервер
const response = await api.updateUserProfile(dataToUpdate);
console.log('[ProfileView] Профиль успешно обновлен:', response.data);
// Обновляем локальный profileData напрямую, не вызывая fetchUser()
profileData.value = {
...profileData.value,
name: dataToUpdate.name,
bio: dataToUpdate.bio,
dateOfBirth: dataToUpdate.dateOfBirth,
gender: dataToUpdate.gender,
location: dataToUpdate.location
};
// Обновляем authUserFromStore напрямую, если он доступен
if (authUserFromStore.value) {
// Копируем новые данные в хранилище авторизации
authUserFromStore.value = {
...authUserFromStore.value,
name: dataToUpdate.name,
bio: dataToUpdate.bio,
dateOfBirth: dataToUpdate.dateOfBirth,
gender: dataToUpdate.gender,
location: dataToUpdate.location
};
}
profileActionSuccess.value = 'Профиль успешно обновлен!';
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
profileActionSuccess.value = '';
}, 3000);
} else {
console.log('[ProfileView] Нет изменений, сохранение не требуется');
}
} catch (err) {
console.error('[ProfileView] Ошибка при сохранении профиля:', err);
profileActionError.value = err.response?.data?.message ||
'Произошла ошибка при обновлении профиля. Пожалуйста, попробуйте позже.';
} finally {
// Переключаемся в режим просмотра и сбрасываем состояние загрузки
isEditMode.value = false;
profileLoading.value = false;
// Гарантированно сбрасываем индикаторы загрузки
loading.value = false;
initialLoading.value = false;
}
} else {
// Переключаемся в режим редактирования
isEditMode.value = true;
// Копируем данные профиля в редактируемый объект
let formattedDate = '';
if (profileData.value && profileData.value.dateOfBirth) {
try {
const date = new Date(profileData.value.dateOfBirth);
formattedDate = date.toISOString().split('T')[0];
} catch (e) {
console.error('[ProfileView] Ошибка форматирования даты:', e);
}
}
editableProfileData.value = {
name: profileData.value?.name || '',
bio: profileData.value?.bio || '',
dateOfBirth: formattedDate,
gender: profileData.value?.gender || '',
location: {
city: profileData.value?.location?.city || ''
}
};
// Сохраняем копию для сравнения
originalProfileData = JSON.stringify(editableProfileData.value);
// Устанавливаем поля поиска
if (profileData.value?.location?.city) {
citySearchQuery.value = profileData.value.location.city;
} else {
citySearchQuery.value = '';
}
if (profileData.value?.gender) {
const genderOption = genderOptions.value.find(option => option.value === profileData.value.gender);
if (genderOption) {
genderSearchQuery.value = genderOption.text;
} else {
genderSearchQuery.value = '';
}
} else {
genderSearchQuery.value = '';
}
}
};
const scrollToPhotos = () => {
// Переключаемся на вкладку с фото
activeTab.value = 'photos';
// Дополнительно можно прокрутить к секции фото после смены вкладки
nextTick(() => {
const photosSection = document.getElementById('photos-section');
if (photosSection) {
photosSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
};
const triggerPhotoUpload = () => {
if (fileInput.value) {
clearMessages(); // Очищаем предыдущие сообщения перед новой загрузкой
fileInput.value.click();
}
};
const handlePhotoSelect = async (event) => {
const files = event.target.files;
if (!files || files.length === 0) return;
// Проверяем форматы и размеры файлов
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
const maxSizeMB = 10; // Максимальный размер файла в MB
const maxSizeBytes = maxSizeMB * 1024 * 1024;
let hasInvalidType = false;
let hasInvalidSize = false;
for (const file of files) {
if (!allowedTypes.includes(file.type)) {
hasInvalidType = true;
break;
}
if (file.size > maxSizeBytes) {
hasInvalidSize = true;
break;
}
}
if (hasInvalidType) {
photoActionError.value = 'Можно загружать только фотографии (JPEG, PNG, WebP)';
event.target.value = '';
return;
}
if (hasInvalidSize) {
photoActionError.value = `Размер файла не должен превышать ${maxSizeMB} МБ`;
event.target.value = '';
return;
}
// Обрабатываем загрузку фото
await uploadPhotos(Array.from(files));
// Очищаем input
event.target.value = '';
};
const getProfileCompletion = () => {
if (!profileData.value) return 0;
const fields = [
profileData.value.name,
profileData.value.bio,
profileData.value.dateOfBirth,
profileData.value.gender,
profileData.value.location?.city,
profileData.value.photos?.length > 0
];
const completed = fields.filter(field => !!field).length;
return Math.round((completed / fields.length) * 100);
};
const getGenderText = (gender) => {
const genderMap = {
'male': 'Мужской',
'female': 'Женский',
'other': 'Другой'
};
return genderMap[gender] || 'Не указан';
};
const formatDate = (dateString) => {
if (!dateString) return '';
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return new Date(dateString).toLocaleDateString('ru-RU', options);
};
const formatShortDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
// Возвращаем дату в формате дд.мм.гггг
return `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
};
const handleProfileUpdate = () => {
// Обновляем данные после редактирования профиля
fetchUser();
clearMessages();
};
const handlePhotoUploadComplete = (result) => {
console.log('[ProfileView] Получено событие photo-upload-complete:', result);
if (result.success > 0) {
photoActionSuccess.value = `Успешно загружено фотографий: ${result.success}`;
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
photoActionSuccess.value = '';
}, 3000);
// Прокручиваем к разделу с фотографиями
setTimeout(() => {
scrollToPhotos();
}, 100);
}
if (result.errors > 0) {
photoActionError.value = `Не удалось загрузить некоторые фото (${result.errors})`;
}
};
// Новый метод для загрузки фотографий непосредственно из ProfileView
const uploadPhotos = async (files) => {
if (!files || files.length === 0) {
console.log('[ProfileView] uploadPhotos: нет файлов для загрузки');
return;
}
console.log(`[ProfileView] uploadPhotos: получено файлов: ${files.length}`);
// Индикатор для отслеживания успешных загрузок
let successCount = 0;
let errorCount = 0;
photoActionLoading.value = true;
clearMessages();
try {
let imageCompression;
try {
// Импортируем библиотеку для сжатия изображений динамически
imageCompression = (await import('browser-image-compression')).default;
} catch (importErr) {
console.error('[ProfileView] Ошибка при импорте библиотеки сжатия:', importErr);
throw new Error('Не удалось загрузить необходимые компоненты. Пожалуйста, обновите страницу и попробуйте снова.');
}
// Настройки для сжатия изображений
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
};
for (let i = 0; i < files.length; i++) {
const originalFile = files[i];
try {
// Сжимаем файл
console.log(`[ProfileView] Сжатие файла ${originalFile.name}...`);
const compressedFile = await imageCompression(originalFile, options);
console.log(`[ProfileView] Файл ${originalFile.name} сжат. Оригинальный размер: ${(originalFile.size / 1024 / 1024).toFixed(2)} MB, Новый размер: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
// Создаем FormData для отправки на сервер
const fd = new FormData();
fd.append('profilePhoto', compressedFile, originalFile.name);
// Отправляем файл
console.log(`[ProfileView] Отправка файла ${originalFile.name} (сжатого) на сервер...`);
const response = await api.uploadUserProfilePhoto(fd);
console.log(`[ProfileView] Ответ сервера (фото ${originalFile.name}):`, response.data);
// Увеличиваем счетчик успешных загрузок
successCount++;
// Вместо вызова fetchUser, получаем данные напрямую и обновляем локальный profileData
if (response.data && response.data.photos) {
// Обновляем фотографии в локальном profileData
profileData.value = {
...profileData.value,
photos: response.data.photos
};
// Обновляем глобальный authUserFromStore
if (authUserFromStore.value) {
authUserFromStore.value = {
...authUserFromStore.value,
photos: response.data.photos
};
}
} else {
// Если по какой-то причине фотографий нет в ответе, сделаем отдельный запрос
// для получения обновленных данных пользователя, но не через fetchUser
const userResponse = await api.getMe();
if (userResponse.data) {
profileData.value = userResponse.data;
// Также обновим глобальное хранилище
if (authUserFromStore.value) {
authUserFromStore.value = userResponse.data;
}
}
}
console.log(`[ProfileView] Данные пользователя обновлены после загрузки фото ${originalFile.name}`);
} catch (err) {
console.error(`[ProfileView] Ошибка при сжатии или загрузке фото ${originalFile.name}:`, err);
errorCount++;
// Показываем более информативное сообщение об ошибке
if (err.message && err.message.includes('network')) {
photoActionError.value = 'Ошибка сети при загрузке фото. Пожалуйста, проверьте подключение к интернету.';
} else if (err.response && err.response.status === 413) {
photoActionError.value = 'Файл слишком большой. Пожалуйста, выберите фото меньшего размера.';
} else {
photoActionError.value = 'Произошла ошибка при загрузке фото';
}
}
}
// Формируем сообщение для пользователя
if (successCount > 0) {
photoActionSuccess.value = `Успешно загружено фотографий: ${successCount}`;
console.log(`[ProfileView] Успешно загружено фотографий: ${successCount}`);
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
photoActionSuccess.value = '';
}, 3000);
// Прокручиваем к разделу с фотографиями
setTimeout(() => {
scrollToPhotos();
}, 100);
}
if (errorCount > 0) {
photoActionError.value = `Не удалось загрузить некоторые фото (${errorCount})`;
console.log(`[ProfileView] Ошибок при загрузке: ${errorCount}`);
}
} catch (err) {
console.error('[ProfileView] Общая ошибка при загрузке фото:', err);
photoActionError.value = 'Произошла ошибка при загрузке фотографий';
} finally {
photoActionLoading.value = false;
}
};
const clearMessages = () => {
photoActionError.value = '';
photoActionSuccess.value = '';
};
const fetchProfileDataLocal = async () => {
console.log('[ProfileView] Начало загрузки данных профиля...');
error.value = '';
loading.value = true;
initialLoading.value = true;
try {
// Проверяем авторизацию
if (!token.value) {
console.log('[ProfileView] Токен отсутствует, авторизация не выполнена');
error.value = "Вы не авторизованы для просмотра этой страницы.";
return;
}
// Если у нас уже есть данные пользователя в хранилище, используем их
if (authUserFromStore.value && Object.keys(authUserFromStore.value).length > 0) {
console.log('[ProfileView] Использую данные профиля из хранилища:', authUserFromStore.value);
profileData.value = { ...authUserFromStore.value };
} else {
// Иначе делаем прямой запрос к API, минуя fetchUser
console.log('[ProfileView] Данных в хранилище нет, делаю прямой запрос к API');
const response = await api.getMe();
console.log('[ProfileView] Ответ от API /auth/me:', response.data);
// Сохраняем данные пользователя локально
profileData.value = response.data;
}
console.log('[ProfileView] Данные профиля успешно загружены:', profileData.value);
} catch (err) {
console.error('[ProfileView] Ошибка при загрузке профиля:', err);
error.value = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Не удалось загрузить данные профиля. Попробуйте позже.';
} finally {
// Всегда сбрасываем индикаторы загрузки
console.log('[ProfileView] Завершение загрузки профиля, сброс индикаторов загрузки');
loading.value = false;
initialLoading.value = false;
}
};
const setAsMainPhoto = async (photoId) => {
if (!photoId) {
photoActionError.value = 'Неверный ID фотографии';
return;
}
photoActionLoading.value = true;
clearMessages();
try {
console.log('[ProfileView] Установка главного фото, ID:', photoId);
const response = await api.setMainPhoto(photoId);
console.log('[ProfileView] Ответ сервера:', response.data);
// Обновляем локальные данные сразу для быстрого отклика
if (profileData.value && profileData.value.photos) {
// Сначала сбрасываем флаг isProfilePhoto для всех фото
profileData.value.photos.forEach(photo => {
photo.isProfilePhoto = photo._id === photoId;
});
// Обновляем данные и в глобальном хранилище если оно доступно
if (authUserFromStore.value && authUserFromStore.value.photos) {
authUserFromStore.value.photos.forEach(photo => {
photo.isProfilePhoto = photo._id === photoId;
});
}
}
photoActionSuccess.value = response.data.message || 'Главное фото обновлено.';
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
photoActionSuccess.value = '';
}, 3000);
} catch (err) {
console.error('[ProfileView] Ошибка при установке главного фото:', err);
photoActionError.value = err.response?.data?.message || 'Не удалось установить главное фото.';
} finally {
photoActionLoading.value = false;
}
};
const confirmDeletePhoto = (photoId) => {
photoToDeleteId.value = photoId;
showDeleteModal.value = true;
clearMessages();
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
photoToDeleteId.value = null;
};
const executeDeletePhoto = async () => {
if (!photoToDeleteId.value) {
photoActionError.value = 'Неверный ID фотографии';
return;
}
photoActionLoading.value = true;
clearMessages();
try {
console.log('[ProfileView] Удаление фото, ID:', photoToDeleteId.value);
const response = await api.deletePhoto(photoToDeleteId.value);
console.log('[ProfileView] Ответ сервера:', response.data);
// Обновляем локальные данные сразу для быстрого отклика
if (profileData.value && profileData.value.photos) {
profileData.value.photos = profileData.value.photos.filter(
photo => photo._id !== photoToDeleteId.value
);
// Также обновляем глобальное хранилище
if (authUserFromStore.value && authUserFromStore.value.photos) {
authUserFromStore.value.photos = authUserFromStore.value.photos.filter(
photo => photo._id !== photoToDeleteId.value
);
}
}
photoActionSuccess.value = response.data.message || 'Фотография удалена.';
closeDeleteModal();
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
photoActionSuccess.value = '';
}, 3000);
} catch (err) {
console.error('[ProfileView] Ошибка при удалении фото:', err);
photoActionError.value = err.response?.data?.message || 'Не удалось удалить фотографию.';
closeDeleteModal();
} finally {
photoActionLoading.value = false;
}
};
const saveProfileChanges = async () => {
profileLoading.value = true;
loading.value = true; // Устанавливаем общий индикатор загрузки
clearProfileMessages();
try {
console.log('[ProfileView] Сохранение данных профиля:', editableProfileData.value);
// Подготавливаем данные для отправки на сервер
const dataToUpdate = { ...editableProfileData.value };
// Обрабатываем дату рождения - если это строка с датой в формате yyyy-MM-dd
// преобразуем её в полноценную дату (в формат ISO)
if (dataToUpdate.dateOfBirth) {
try {
// Получаем дату в формате ISO с поправкой на часовой пояс
const dateObj = new Date(dataToUpdate.dateOfBirth);
if (!isNaN(dateObj.getTime())) { // Проверяем, что дата валидна
dataToUpdate.dateOfBirth = dateObj.toISOString();
}
} catch (e) {
console.error('[ProfileView] Ошибка обработки даты:', e);
// Оставляем как есть, если возникла ошибка
}
console.log('[ProfileView] Дата рождения для отправки:', dataToUpdate.dateOfBirth);
}
const response = await api.updateUserProfile(dataToUpdate);
console.log('[ProfileView] Ответ от сервера (профиль):', response.data);
// Обновляем данные в профиле
await fetchUser();
profileActionSuccess.value = 'Профиль успешно обновлен!';
// Автоматически скрываем сообщение через 3 секунды
setTimeout(() => {
profileActionSuccess.value = '';
}, 3000);
} catch (err) {
console.error('[ProfileView] Ошибка при обновлении профиля:', err);
profileActionError.value = err.response?.data?.message ||
'Произошла ошибка при обновлении профиля. Пожалуйста, попробуйте позже.';
} finally {
profileLoading.value = false;
loading.value = false; // Сбрасываем общий индикатор загрузки
initialLoading.value = false; // Убеждаемся, что initialLoading тоже сброшен
}
};
const checkForChanges = () => {
// Сравниваем текущие редактируемые данные с оригинальными
const currentData = JSON.stringify(editableProfileData.value);
console.log('[ProfileView] Проверка изменений:');
console.log('Оригинал:', originalProfileData);
console.log('Текущие:', currentData);
console.log('Есть изменения:', currentData !== originalProfileData);
return currentData !== originalProfileData;
};
// Добавляем отсутствующие функции
const logoutUser = async () => {
try {
await logout();
// Перенаправляем на страницу входа после выхода
window.location.href = '/login';
} catch (err) {
console.error('[ProfileView] Ошибка при выходе:', err);
}
};
const clearProfileMessages = () => {
profileActionError.value = '';
profileActionSuccess.value = '';
};
// Функции для работы с городами
const onCitySearch = (event) => {
const query = event.target.value.toLowerCase().trim();
citySearchQuery.value = event.target.value;
if (query.length > 0) {
// Простой список российских городов для демонстрации
const allCities = [
'Москва', 'Санкт-Петербург', 'Новосибирск', 'Екатеринбург', 'Казань',
'Нижний Новгород', 'Челябинск', 'Самара', 'Омск', 'Ростов-на-Дону',
'Уфа', 'Красноярск', 'Воронеж', 'Пермь', 'Волгоград', 'Краснодар',
'Саратов', 'Тюмень', 'Тольятти', 'Ижевск', 'Барнаул', 'Ульяновск',
'Иркутск', 'Хабаровск', 'Ярославль', 'Владивосток', 'Махачкала',
'Томск', 'Оренбург', 'Кемерово', 'Новокузнецк', 'Рязань', 'Пенза',
'Астрахань', 'Липецк', 'Тула', 'Киров', 'Чебоксары', 'Калининград'
];
filteredCities.value = allCities.filter(city =>
city.toLowerCase().includes(query)
).slice(0, 10); // Ограничиваем до 10 результатов
showCityList.value = filteredCities.value.length > 0;
} else {
filteredCities.value = [];
showCityList.value = false;
}
};
const selectCity = (city) => {
citySearchQuery.value = city;
editableProfileData.value.location.city = city;
showCityList.value = false;
};
const clearCitySelection = () => {
citySearchQuery.value = '';
editableProfileData.value.location.city = '';
showCityList.value = false;
};
// Функции для работы с полом
const onGenderSearch = (event) => {
const query = event.target.value.toLowerCase().trim();
genderSearchQuery.value = event.target.value;
if (query.length > 0) {
filteredGenders.value = genderOptions.value.filter(option =>
option.text.toLowerCase().includes(query)
);
showGenderList.value = filteredGenders.value.length > 0;
} else {
filteredGenders.value = genderOptions.value;
showGenderList.value = true;
}
};
const selectGender = (option) => {
genderSearchQuery.value = option.text;
editableProfileData.value.gender = option.value;
showGenderList.value = false;
};
const clearGenderSelection = () => {
genderSearchQuery.value = '';
editableProfileData.value.gender = '';
showGenderList.value = false;
};
// Закрытие выпадающих списков при клике вне их
const handleClickOutside = (event) => {
if (!event.target.closest('.city-input-wrapper')) {
showCityList.value = false;
showGenderList.value = false;
}
};
// Добавляем onMounted для инициализации компонента
onMounted(async () => {
console.log('[ProfileView] Компонент смонтирован, начинаем загрузку профиля...');
// Добавляем обработчик для закрытия выпадающих списков
document.addEventListener('click', handleClickOutside);
// Загружаем данные профиля
await fetchProfileDataLocal();
});
// Очистка при размонтировании
import { onUnmounted } from 'vue';
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
/* Базовые стили */
.app-profile-view {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
width: 100%;
box-sizing: border-box;
}
/* Loading State */
.loading-section {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error State */
.error-section {
padding: 2rem 0;
}
.error-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.error-card i {
font-size: 3rem;
color: #dc3545;
margin-bottom: 1rem;
}
.retry-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 25px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
}
.retry-btn:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
/* Main Content */
.profile-content {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
padding-bottom: calc(1rem + var(--nav-height, 60px));
-webkit-overflow-scrolling: touch;
}
.profile-layout {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: max-content;
}
/* Profile Header Card */
.profile-header-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.profile-header-content {
display: flex;
flex-direction: column;
}
/* Avatar Section */
.avatar-section {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
justify-content: space-between;
}
.avatar-container {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
border: 3px solid rgba(102, 126, 234, 0.3);
flex-shrink: 0;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
font-size: 2rem;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 0.3s;
border-radius: 50%;
}
.avatar-container:hover .avatar-overlay {
opacity: 1;
}
.change-avatar-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
}
.change-avatar-btn:hover {
background: #5a6abf;
transform: scale(1.1);
}
.user-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.user-name {
font-size: 1.3rem;
font-weight: 600;
margin: 0 0 0.2rem;
color: #333;
word-wrap: break-word;
overflow-wrap: break-word;
}
.user-email {
font-size: 0.85rem;
color: #6c757d;
margin: 0 0 0.5rem;
word-wrap: break-word;
overflow-wrap: break-word;
}
.user-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
padding: 0.25rem 0.6rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.2rem;
}
.badge.verified {
background: linear-gradient(45deg, #28a745, #20c997);
color: white;
}
.badge.member-since {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
}
.header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
/* Edit Button */
.edit-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
border: 1px solid rgba(102, 126, 234, 0.3);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.4rem;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.edit-btn:hover {
background: linear-gradient(45deg, #5a6abf, #6a4190);
border-color: rgba(102, 126, 234, 0.5);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(102, 126, 234, 0.25);
}
.edit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Stats Section */
.stats-section {
display: flex;
justify-content: space-between;
background: rgba(243, 245, 250, 0.6);
border-radius: 12px;
padding: 1rem;
margin-top: 0.8rem;
gap: 0.5rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 33.33%;
position: relative;
text-align: center;
padding: 0.5rem;
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border-radius: 50%;
background: rgba(102, 126, 234, 0.15);
color: #667eea;
margin-bottom: 0.5rem;
}
.stat-icon i {
font-size: 1.3rem;
line-height: 1;
}
.stat-info {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.1rem;
}
.stat-label {
font-size: 0.75rem;
color: #6c757d;
text-align: center;
}
/* Tabs */
.profile-tabs {
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
}
.tabs-container {
display: flex;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.tab-btn {
flex: 1;
padding: 0.8rem 1rem;
text-align: center;
font-size: 0.85rem;
font-weight: 500;
color: #6c757d;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.tab-btn.active {
color: #667eea;
font-weight: 600;
}
.tab-btn.active:after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(45deg, #667eea, #764ba2);
border-radius: 3px 3px 0 0;
}
.tab-btn:hover {
color: #5a6abf;
background: rgba(102, 126, 234, 0.05);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 0.3rem;
border-radius: 9px;
font-size: 0.7rem;
font-weight: 600;
background: #e9ecef;
color: #6c757d;
}
.tab-btn.active .tab-badge {
background: #667eea;
color: white;
}
/* Tabs Content */
.tabs-content {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
overflow: hidden;
padding: 0;
}
.tab-pane {
min-height: 200px;
}
/* Info Card */
.info-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 0;
border: none;
box-shadow: none;
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: rgba(248, 249, 250, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.card-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-content {
padding: 1.25rem;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item label {
font-size: 0.8rem;
font-weight: 500;
color: #6c757d;
}
.info-item span {
font-size: 0.95rem;
color: #333;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.bio-text {
line-height: 1.5;
}
/* Forms */
.form-input, .form-textarea {
width: 100%;
padding: 0.6rem 0.8rem;
border-radius: 8px;
border: 1px solid #ced4da;
font-size: 0.9rem;
background: white;
transition: all 0.3s ease;
}
.form-input:focus, .form-textarea:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
outline: none;
}
.form-input:disabled, .form-textarea:disabled {
background: #f8f9fa;
cursor: not-allowed;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
/* Photo Grid */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.8rem;
}
.photo-item {
aspect-ratio: 1/1;
position: relative;
border-radius: 10px;
overflow: hidden;
transition: transform 0.3s ease;
cursor: pointer;
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter 0.3s ease;
}
/* Photo Actions */
.photo-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 0.4rem;
padding: 0.4rem;
background: linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.photo-item:hover .photo-actions {
opacity: 1;
}
.photo-item:hover .photo-image {
filter: brightness(0.9);
}
.main-badge {
position: absolute;
top: 0.4rem;
left: 0.4rem;
background: rgba(255, 255, 255, 0.85);
padding: 0.25rem 0.5rem;
border-radius: 8px;
font-size: 0.65rem;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.main-badge i {
color: #ffc107;
}
.action-btn {
border: none;
border-radius: 6px;
padding: 0.35rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.small {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.action-btn.primary {
background: rgba(102, 126, 234, 0.9);
color: white;
}
.action-btn.danger {
background: rgba(220, 53, 69, 0.9);
color: white;
}
.action-btn.primary:hover {
background: #5a6abf;
}
.action-btn.danger:hover {
background: #c82333;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Add Photo Button */
.add-photo-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
border: none;
color: white;
padding: 0.4rem 0.8rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.3rem;
}
.add-photo-btn:hover {
background: linear-gradient(45deg, #5a6abf, #6a4190);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.3);
}
.add-photo-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Empty Photos */
.empty-photos {
padding: 0.5rem 0;
}
/* Delete Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.delete-modal {
width: 90%;
max-width: 400px;
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
animation: modal-in 0.3s ease;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: rgba(248, 249, 250, 0.8);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.close-btn {
background: none;
border: none;
padding: 0.3rem;
font-size: 1.2rem;
color: #6c757d;
cursor: pointer;
transition: color 0.2s;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
background: rgba(0, 0, 0, 0.05);
}
.modal-content {
padding: 1.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.8rem;
padding: 1rem 1.25rem;
background: rgba(248, 249, 250, 0.8);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.action-btn.secondary {
background: #f8f9fa;
color: #333;
border: 1px solid #ced4da;
padding: 0.5rem 1rem;
}
.action-btn.secondary:hover {
background: #e9ecef;
}
.action-btn.danger {
background: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
}
.action-btn.danger:hover {
background: #c82333;
}
/* Alert Messages */
.alert {
padding: 0.8rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.9rem;
animation: alert-in 0.3s ease;
}
@keyframes alert-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.alert.error {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.2);
}
.alert.success {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.2);
}
.alert.info {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
border: 1px solid rgba(102, 126, 234, 0.2);
}
/* Spinner Small */
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* No Data State */
.no-data-section {
padding: 2rem 0;
}
.no-data-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.no-data-card i {
font-size: 3rem;
color: #6c757d;
margin-bottom: 1rem;
}
/* Select Wrapper and Dropdown */
.select-wrapper {
position: relative;
}
.clear-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
font-size: 0.8rem;
padding: 0.2rem;
border-radius: 50%;
}
.clear-btn:hover {
color: #333;
background: rgba(0, 0, 0, 0.05);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
margin-top: 0.3rem;
max-height: 200px;
overflow-y: auto;
z-index: 10;
}
.dropdown-option {
padding: 0.6rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.dropdown-option:hover {
background: rgba(102, 126, 234, 0.1);
}
.logout-section {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
.logout-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem 1rem;
border-radius: 25px;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.logout-btn:hover {
color: #c82333;
background: rgba(220, 53, 69, 0.1);
border-color: rgba(220, 53, 69, 0.2);
}
/* Upload Zone */
.upload-zone {
position: relative;
padding: 2rem 1.5rem;
border: 2px dashed #dee2e6;
border-radius: 16px;
background: rgba(248, 249, 250, 0.5);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.upload-zone:hover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
transform: translateY(-2px);
}
.upload-icon-container {
position: relative;
margin-bottom: 1.5rem;
}
.upload-icon-bg {
width: 70px;
height: 70px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.3);
position: relative;
z-index: 2;
}
.upload-icon {
font-size: 2rem;
color: white;
}
.upload-pulse {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
border-radius: 50%;
background: rgba(102, 126, 234, 0.3);
animation: pulse 2s infinite;
z-index: 1;
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.8;
}
70% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
.upload-spinner {
width: 2rem;
height: 2rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.upload-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin: 0 0 0.5rem;
}
.upload-description {
font-size: 0.9rem;
color: #6c757d;
margin: 0 0 1.5rem;
max-width: 400px;
}
.upload-features {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.feature-item i {
font-size: 1.2rem;
color: #667eea;
}
.feature-item span {
font-size: 0.8rem;
color: #6c757d;
}
.upload-btn-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
}
.upload-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 25px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
box-shadow: 0 6px 15px rgba(102, 126, 234, 0.3);
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.upload-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.upload-hint small {
font-size: 0.75rem;
color: #6c757d;
}
/* Адаптивные стили */
@media (min-width: 768px) {
/* Desktop styles */
.profile-header-content {
flex-direction: row;
justify-content: space-between;
}
.avatar-section {
flex: 1;
justify-content: flex-start;
margin-bottom: 0;
}
.stats-section {
width: 320px;
margin-top: 0;
}
.info-grid {
grid-template-columns: repeat(2, 1fr);
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.tabs-content {
padding: 0;
}
}
@media (max-width: 767px) {
/* Tablet and mobile styles */
.profile-header-content {
flex-direction: column;
}
.avatar-section {
flex-wrap: wrap;
}
.avatar-container {
margin: 0 auto;
}
.user-meta {
text-align: center;
width: 100%;
}
.user-badges {
justify-content: center;
}
.header-actions {
margin-top: 0.8rem;
justify-content: center;
width: 100%;
}
.stats-section {
flex-wrap: wrap;
gap: 0.5rem;
}
.stat-item {
flex: 1;
justify-content: center;
min-width: 100px;
}
.info-grid {
grid-template-columns: 1fr;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.tab-btn span {
display: none;
}
.tab-btn i {
font-size: 1.2rem;
}
}
@media (max-width: 480px) {
/* Small mobile styles */
.container {
padding: 0 0.75rem;
}
.profile-header-card,
.profile-tabs,
.tabs-content {
padding: 1rem;
border-radius: 12px;
}
.card-header,
.card-content {
padding: 1rem;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.5rem;
}
.avatar-container {
width: 70px;
height: 70px;
}
.user-name {
font-size: 1.2rem;
}
.stat-item {
min-width: 80px;
}
.upload-features {
gap: 1rem;
}
.upload-icon-bg {
width: 60px;
height: 60px;
}
.upload-pulse {
width: 60px;
height: 60px;
}
.upload-icon {
font-size: 1.7rem;
}
}
/* Темная тема заменена на светлую */
@media (prefers-color-scheme: dark) {
.profile-header-card,
.profile-tabs,
.tabs-content,
.info-card {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.05);
}
.user-name,
.stat-value,
.tab-btn.active,
.card-header h3 {
color: #333;
}
.user-email,
.stat-label,
.tab-btn,
.bio-text,
.info-item span {
color: #6c757d;
}
.info-item label {
color: #6c757d;
}
.card-header,
.stats-section {
background: rgba(248, 249, 250, 0.8);
}
.form-input, .form-textarea {
background: #ffffff;
border-color: #ced4da;
color: #333;
}
.form-input:focus, .form-textarea:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.form-input:disabled, .form-textarea:disabled {
background: #f8f9fa;
}
.upload-zone {
background: rgba(248, 249, 250, 0.5);
border-color: #dee2e6;
}
.upload-title {
color: #333;
}
.upload-description,
.upload-hint small,
.feature-item span {
color: #6c757d;
}
.delete-modal,
.dropdown {
background: #ffffff;
}
.dropdown-option:hover {
background: rgba(102, 126, 234, 0.1);
}
.modal-header,
.modal-actions {
background: #f8f9fa;
}
.action-btn.secondary {
background: #f8f9fa;
border-color: #ced4da;
color: #333;
}
.action-btn.secondary:hover {
background: #e9ecef;
}
.main-badge {
background: rgba(255, 255, 255, 0.85);
color: #333;
}
}
/* Print Styles */
@media print {
.profile-header,
.header-actions,
.action-btn,
.photo-actions,
.modal-overlay {
display: none !important;
}
.profile-content {
padding: 0;
}
.profile-card,
.info-card {
box-shadow: none;
border: 1px solid #000;
}
}
</style>