Reflex/src/views/ProfileView.vue

1740 lines
50 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="modern-profile-view">
<!-- Header Section -->
<div class="profile-header">
<div class="container">
<div class="header-content">
<div class="profile-title">
<h1 class="title-gradient">Мой профиль</h1>
<p class="subtitle">Управление личными данными и настройками</p>
</div>
<div class="header-actions">
<button
class="action-btn primary"
@click="toggleEditMode"
:disabled="loading"
>
<i class="icon" :class="isEditMode ? 'bi-eye' : 'bi-pencil'"></i>
{{ isEditMode ? 'Просмотр' : 'Редактировать' }}
</button>
</div>
</div>
</div>
</div>
<!-- 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="main-content">
<div class="container">
<div class="profile-layout">
<!-- Profile Card Section -->
<div class="profile-card-section">
<div class="profile-card">
<!-- Avatar Section -->
<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-info">
<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>
<!-- Stats Section -->
<div class="stats-section">
<div class="stat-item">
<span class="stat-value">{{ profileData.photos?.length || 0 }}</span>
<span class="stat-label">Фото</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ profileData.matches?.length || 0 }}</span>
<span class="stat-label">Совпадения</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ getProfileCompletion() }}%</span>
<span class="stat-label">Заполнено</span>
</div>
</div>
</div>
</div>
<!-- Details Section -->
<div class="details-section">
<!-- Basic Info -->
<div class="info-card">
<div class="card-header">
<h3><i class="bi-person-lines-fill"></i> Основная информация</h3>
<div class="header-actions" v-if="isEditMode">
<button class="action-btn small primary" @click="saveProfileChanges" :disabled="profileLoading">
<span v-if="profileLoading" class="spinner-small"></span>
<i v-else class="bi-check"></i>
Сохранить
</button>
</div>
</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="city-input-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="city-clear-btn"
@click="clearGenderSelection"
v-if="editableProfileData.gender"
:disabled="profileLoading"
>
<i class="bi-x"></i>
</button>
<div class="city-dropdown" v-if="showGenderList && filteredGenders.length > 0">
<div
v-for="option in filteredGenders"
:key="option.value"
class="city-option"
@click="selectGender(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="city-input-wrapper">
<input
type="text"
class="form-input"
id="editCity"
v-model="citySearchQuery"
placeholder="Начните вводить название города..."
@focus="showCityList = true"
@input="onCitySearch"
:disabled="profileLoading"
/>
<button
type="button"
class="city-clear-btn"
@click="clearCitySelection"
v-if="editableProfileData.location && editableProfileData.location.city"
:disabled="profileLoading"
>
<i class="bi-x"></i>
</button>
<div class="city-dropdown" v-if="showCityList && filteredCities.length > 0">
<div
v-for="city in filteredCities"
:key="city"
class="city-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>
</div> <!-- Photos Section -->
<div class="info-card" id="photos-section">
<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 v-if="isEditMode" 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">
<i class="bi-image"></i>
<h4>Нет фотографий</h4>
<p>Добавьте фотографии, чтобы сделать профиль привлекательнее</p>
<button class="action-btn primary" @click="triggerPhotoUpload" :disabled="photoActionLoading">
<span v-if="photoActionLoading" class="spinner-small"></span>
<i v-else class="bi-plus"></i>
{{ photoActionLoading ? 'Загрузка...' : 'Добавить первое фото' }}
</button>
</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>
<!-- 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 } = useAuth();
const profileData = ref(null);
const loading = ref(true);
const initialLoading = ref(true);
const error = ref('');
const isEditMode = ref(false);
// Для управления фото
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 = () => {
isEditMode.value = !isEditMode.value;
if (isEditMode.value) {
// При входе в режим редактирования копируем данные профиля в редактируемый объект
// Преобразуем формат даты из ISO в yyyy-MM-dd
let formattedDate = '';
if (profileData.value.dateOfBirth) {
try {
const date = new Date(profileData.value.dateOfBirth);
formattedDate = date.toISOString().split('T')[0]; // Получаем только yyyy-MM-dd часть
} catch (e) {
console.error('Ошибка форматирования даты:', e);
formattedDate = '';
}
}
editableProfileData.value = {
name: profileData.value.name || '',
bio: profileData.value.bio || '',
dateOfBirth: formattedDate,
gender: profileData.value.gender || '',
location: {
city: profileData.value.location?.city || '',
// Можно добавить другие поля местоположения, если они есть
}
};
// Устанавливаем поисковый запрос города
if (profileData.value.location?.city) {
citySearchQuery.value = profileData.value.location.city;
}
} else {
// При выходе из режима редактирования сбрасываем ошибки и сообщения
clearProfileMessages();
}
};
const scrollToPhotos = () => {
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.getMonth() + 1}.${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++;
// Обновляем данные пользователя после каждой успешной загрузки
await fetchUser();
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 = 'Файл слишком большой. Пожалуйста, выберите фото меньшего размера.';
}
}
}
// Формируем сообщение для пользователя
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 () => {
loading.value = true;
error.value = '';
if (!isAuthenticated.value || !token.value) {
error.value = "Вы не авторизованы для просмотра этой страницы.";
loading.value = false;
initialLoading.value = false;
return;
}
try {
await fetchUser();
} catch (err) {
console.error('[ProfileView] Ошибка при вызове fetchUser:', err);
error.value = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Не удалось загрузить данные профиля. Попробуйте позже.';
} finally {
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) {
profileData.value.photos.forEach(photo => {
photo.isProfilePhoto = photo._id === photoId;
});
}
// Затем получаем актуальные данные с сервера
await fetchUser();
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
);
}
// Затем получаем актуальные данные с сервера
await fetchUser();
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;
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;
}
};
// Методы для работы с городами
const loadCities = async () => {
if (cities.length > 0) return;
try {
// Используем правильный путь к файлу для Vite
const response = await import('@/assets/russian-cities.json');
const rawData = response.default || [];
// Проверяем, что данные - массив
if (Array.isArray(rawData)) {
// Извлекаем названия городов из объектов
cities = rawData.map(city => {
// Проверяем, является ли city объектом и содержит ли поле name
return typeof city === 'object' && city !== null && city.name ? city.name : null;
}).filter(cityName => cityName !== null); // Удаляем null элементы
} else {
cities = [];
console.error('[ProfileView] Неверный формат данных городов:', typeof rawData);
}
console.log('[ProfileView] Загружен список городов:', cities.length);
} catch (err) {
console.error('[ProfileView] Ошибка при загрузке списка городов:', err);
cities = []; // Инициализируем пустым массивом в случае ошибки
}
};
const onCitySearch = () => {
if (!cities.length) {
loadCities();
return;
}
if (citySearchQuery.value.length < 2) {
filteredCities.value = [];
return;
}
const query = citySearchQuery.value.toLowerCase().trim();
filteredCities.value = cities
.filter(city => typeof city === 'string' && city.toLowerCase().includes(query))
.slice(0, 10); // Ограничиваем количество результатов
};
const selectCity = (city) => {
console.log('[ProfileView] Выбран город:', city, typeof city);
citySearchQuery.value = city;
if (!editableProfileData.value.location) {
editableProfileData.value.location = {};
}
editableProfileData.value.location.city = city;
showCityList.value = false;
};
const clearCitySelection = () => {
citySearchQuery.value = '';
if (editableProfileData.value.location) {
editableProfileData.value.location.city = '';
}
};
// Методы для выбора пола
const onGenderSearch = () => {
filteredGenders.value = genderOptions.value.filter(option =>
option.text.toLowerCase().includes(genderSearchQuery.value.toLowerCase())
);
};
const selectGender = (genderOption) => {
genderSearchQuery.value = genderOption.text;
editableProfileData.value.gender = genderOption.value;
showGenderList.value = false;
};
const clearGenderSelection = () => {
genderSearchQuery.value = '';
editableProfileData.value.gender = '';
};
const clearProfileMessages = () => {
profileActionError.value = '';
profileActionSuccess.value = '';
};
watch(authUserFromStore, (newUser) => {
if (newUser) {
profileData.value = { ...newUser };
console.log('[ProfileView] Данные профиля обновлены из authUserFromStore:', profileData.value);
}
}, { immediate: true, deep: true });
watch(profileData, (newData) => {
if (newData) {
editableProfileData.value = { ...newData };
}
}, { immediate: true, deep: true });
// Lifecycle
onMounted(async () => {
if (!authUserFromStore.value || Object.keys(authUserFromStore.value).length === 0) {
await fetchProfileDataLocal();
} else {
profileData.value = { ...authUserFromStore.value };
loading.value = false;
initialLoading.value = false;
console.log('[ProfileView] Данные профиля уже были в хранилище:', profileData.value);
}
});
</script>
<style scoped>
/* Bootstrap Icons загружаются в index.html */
/* Global Reset and Base Styles */
.modern-profile-view {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
width: 100%;
overflow-x: hidden;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
width: 100%;
}
/* Header Section */
.profile-header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 2rem 0;
color: white;
width: 100%;
margin: 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.profile-title h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(45deg, #fff, #f0f0f0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.profile-title .subtitle {
margin: 0.5rem 0 0 0;
font-size: 1.1rem;
opacity: 0.9;
}
/* Action Buttons */
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 50px;
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.action-btn.primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.action-btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.action-btn.secondary {
background: #f8f9fa;
color: #495057;
border: 1px solid #dee2e6;
}
.action-btn.secondary:hover {
background: #e9ecef;
transform: translateY(-1px);
}
.action-btn.danger {
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
color: white;
box-shadow: 0 4px 15px rgba(238, 90, 36, 0.4);
}
.action-btn.danger:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(238, 90, 36, 0.6);
}
.action-btn.small {
padding: 0.5rem 1rem;
font-size: 0.8rem;
min-width: 40px;
height: 40px;
border-radius: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.small i {
font-size: 1rem;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
/* Loading State */
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
color: white;
}
.loading-spinner {
text-align: center;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-left: 4px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-left: 2px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error Section */
.error-section {
padding: 4rem 0;
}
.error-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 3rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.error-card i {
font-size: 3rem;
color: #dc3545;
margin-bottom: 1rem;
}
.error-card h3 {
color: #495057;
margin-bottom: 1rem;
}
.retry-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 50px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
/* Main Content */
.main-content {
padding: 2rem 0 4rem;
}
.profile-layout {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
align-items: start;
}
/* Profile Card */
.profile-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
position: sticky;
top: 2rem;
}
.avatar-section {
text-align: center;
margin-bottom: 2rem;
}
.avatar-container {
position: relative;
display: inline-block;
margin-bottom: 1rem;
}
.avatar-image {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(45deg, #e9ecef, #f8f9fa);
display: flex;
align-items: center;
justify-content: center;
border: 4px solid white;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.avatar-placeholder i {
font-size: 3rem;
color: #6c757d;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.3s ease;
z-index: 5;
}
.avatar-container:hover .avatar-overlay {
opacity: 1;
}
.change-avatar-btn {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
}
.user-name {
font-size: 1.5rem;
font-weight: 700;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
.user-email {
color: #6c757d;
margin: 0 0 1rem 0;
}
.user-badges {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.verified {
background: linear-gradient(45deg, #28a745, #20c997);
color: white;
}
.badge.member-since {
background: #e9ecef;
color: #495057;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e9ecef;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #667eea;
}
.stat-label {
font-size: 0.8rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Details Section */
.details-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
.info-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-header {
padding: 1.5rem 2rem;
background: linear-gradient(45deg, #f8f9fa, #e9ecef);
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
color: #495057;
margin-bottom: 1rem;
font-size: 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-content {
padding: 2rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
font-weight: 600;
color: #495057;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
color: #6c757d;
font-size: 1rem;
}
.bio-text {
font-style: italic;
line-height: 1.6;
}
/* Стили формы */
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid #dee2e6;
background: #f8f9fa;
font-size: 0.95rem;
transition: all 0.3s ease;
color: #495057;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
background: white;
}
.form-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background: #e9ecef;
}
.form-textarea {
min-height: 100px;
resize: vertical;
line-height: 1.5;
}
/* Стили для выпадающего списка городов */
.city-input-wrapper {
position: relative;
width: 100%;
}
.city-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.city-option {
padding: 0.75rem 1rem;
cursor: pointer;
color: #495057;
transition: all 0.2s ease;
}
.city-option:hover {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
.city-clear-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
}
.city-clear-btn:hover {
color: #dc3545;
}
.card-header h3 {
margin-bottom: 0 !important;
}
/* Фото сетка */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.photo-item {
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
aspect-ratio: 1 / 1;
}
.photo-item.main-photo {
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
transform: scale(1.05);
z-index: 2;
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.main-badge {
position: absolute;
top: 10px;
left: 10px;
background: rgba(102, 126, 234, 0.9);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.25rem;
}
.photo-actions {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 0.5rem;
}
.empty-photos {
text-align: center;
padding: 3rem 0;
}
.empty-photos i {
font-size: 3rem;
color: #dee2e6;
margin-bottom: 1rem;
}
.empty-photos h4 {
margin: 0 0 0.5rem;
color: #495057;
}
.empty-photos p {
color: #6c757d;
margin-bottom: 1.5rem;
}
/* Адаптивные стили для разных устройств */
@media (max-width: 576px) {
.profile-card {
padding: 1.5rem;
border-radius: 15px;
}
.profile-header {
padding: 0.8rem 0;
}
.header-title {
font-size: 1.6rem;
}
.profile-photo {
width: 110px;
height: 110px;
}
.profile-details {
padding: 1rem 0;
}
.profile-name {
font-size: 1.6rem;
}
.section-header {
padding: 0.8rem 1.2rem;
}
.card-header h3 {
font-size: 1.2rem;
}
.edit-btn, .save-btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.8rem;
}
.photo-item, .add-photo-btn {
height: 120px;
}
.form-input, .form-textarea {
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
}
.form-label {
font-size: 0.85rem;
}
}
/* Малые телефоны */
@media (max-width: 375px) {
.profile-photo {
width: 100px;
height: 100px;
}
.profile-name {
font-size: 1.4rem;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.photo-item, .add-photo-btn {
height: 100px;
}
.edit-btn, .save-btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
}
/* Большие телефоны и малые планшеты */
@media (min-width: 577px) and (max-width: 767px) {
.profile-photo {
width: 140px;
height: 140px;
}
.info-grid {
grid-template-columns: repeat(2, 1fr);
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.info-item.full-width {
grid-column: 1 / 3;
}
}
/* Планшеты */
@media (min-width: 768px) and (max-width: 991px) {
.container {
max-width: 720px;
}
.profile-main {
display: grid;
grid-template-columns: 180px 1fr;
gap: 2rem;
}
.profile-photo-section {
margin-bottom: 0;
}
.profile-details {
text-align: left;
padding-left: 0;
}
}
/* Малые настольные */
@media (min-width: 992px) and (max-width: 1199px) {
.container {
max-width: 960px;
}
.profile-main {
display: grid;
grid-template-columns: 200px 1fr;
gap: 2rem;
}
.profile-photo-section {
margin-bottom: 0;
}
.profile-details {
text-align: left;
padding-left: 0;
}
}
/* Большие настольные */
@media (min-width: 1200px) {
.container {
max-width: 1140px;
}
.profile-main {
display: grid;
grid-template-columns: 220px 1fr;
gap: 2.5rem;
}
.profile-photo-section {
margin-bottom: 0;
}
.profile-details {
text-align: left;
padding-left: 0;
}
}
/* Ландшафтная ориентация на мобильных */
@media (max-height: 500px) and (orientation: landscape) {
.modern-profile-view {
padding-bottom: 2rem;
}
.profile-photo {
width: 90px;
height: 90px;
margin-bottom: 0.5rem;
}
.profile-main {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.5rem;
}
.profile-details {
text-align: left;
}
.section-content {
padding: 1rem;
}
}
</style>