фотографии профиля

This commit is contained in:
Professional 2025-05-21 23:43:24 +07:00
parent 58f6df7d69
commit 1e0c1dc9b4
8 changed files with 462 additions and 97 deletions

View File

@ -194,7 +194,8 @@ const uploadUserProfilePhoto = async (req, res, next) => {
const newPhoto = {
url: result.secure_url,
public_id: result.public_id,
isProfilePhoto: user.photos.length === 0,
// Если это первая фотография, делаем ее главной. Иначе - нет.
isProfilePhoto: user.photos.length === 0,
};
// Добавляем новое фото в массив фотографий пользователя
@ -202,19 +203,23 @@ const uploadUserProfilePhoto = async (req, res, next) => {
await user.save();
// Получаем обновленного пользователя с обновленным массивом фотографий
const updatedUser = await User.findById(req.user._id);
const updatedUser = await User.findById(req.user._id).lean(); // .lean() для простого объекта
// Важно! Явно создаем массив allPhotos для ответа, чтобы убедиться, что структура каждого объекта фото совпадает
const allPhotos = updatedUser.photos.map(photo => ({
_id: photo._id, // Добавляем _id для фото
url: photo.url,
public_id: photo.public_id,
isProfilePhoto: photo.isProfilePhoto
}));
// Найдем только что добавленное фото в обновленном массиве, чтобы вернуть его с _id
const addedPhotoWithId = allPhotos.find(p => p.public_id === newPhoto.public_id);
// Возвращаем ответ с точной структурой, ожидаемой тестами
res.status(200).json({
message: 'Фотография успешно загружена!',
photo: newPhoto,
photo: addedPhotoWithId, // Возвращаем фото с _id
allPhotos: allPhotos
});
@ -227,12 +232,115 @@ const uploadUserProfilePhoto = async (req, res, next) => {
}
};
// Другие возможные функции для userController (например, getUserById, getAllUsers для админа и т.д.)
// const getUserById = async (req, res, next) => { ... };
// @desc Установить фотографию как главную
// @route PUT /api/users/profile/photo/:photoId/set-main
// @access Private
const setMainPhoto = async (req, res, next) => {
try {
const user = await User.findById(req.user._id);
const { photoId } = req.params;
if (!user) {
const error = new Error('Пользователь не найден.');
error.statusCode = 404;
return next(error);
}
let photoFound = false;
user.photos.forEach(photo => {
if (photo._id.toString() === photoId) {
photo.isProfilePhoto = true;
photoFound = true;
} else {
photo.isProfilePhoto = false;
}
});
if (!photoFound) {
const error = new Error('Фотография не найдена.');
error.statusCode = 404;
return next(error);
}
await user.save();
const updatedUser = await User.findById(req.user._id).lean();
res.status(200).json({
message: 'Главная фотография успешно обновлена.',
photos: updatedUser.photos.map(p => ({_id: p._id, url: p.url, public_id: p.public_id, isProfilePhoto: p.isProfilePhoto }))
});
} catch (error) {
console.error('[USER_CTRL] Ошибка при установке главной фотографии:', error.message);
next(error);
}
};
// @desc Удалить фотографию пользователя
// @route DELETE /api/users/profile/photo/:photoId
// @access Private
const deletePhoto = async (req, res, next) => {
try {
const user = await User.findById(req.user._id);
const { photoId } = req.params;
if (!user) {
const error = new Error('Пользователь не найден.');
error.statusCode = 404;
return next(error);
}
const photoIndex = user.photos.findIndex(p => p._id.toString() === photoId);
if (photoIndex === -1) {
const error = new Error('Фотография не найдена.');
error.statusCode = 404;
return next(error);
}
const photoToDelete = user.photos[photoIndex];
// Удаление из Cloudinary
if (photoToDelete.public_id) {
try {
await cloudinary.uploader.destroy(photoToDelete.public_id);
console.log(`[USER_CTRL] Фото ${photoToDelete.public_id} удалено из Cloudinary.`);
} catch (cloudinaryError) {
console.error('[USER_CTRL] Ошибка при удалении фото из Cloudinary:', cloudinaryError.message);
// Не прерываем процесс, если фото не удалось удалить из Cloudinary, но логируем ошибку
// Можно добавить более сложную логику обработки, например, пометить фото для последующего удаления
}
}
// Удаление из массива photos пользователя
user.photos.splice(photoIndex, 1);
// Если удаленная фотография была главной и остались другие фотографии,
// делаем первую из оставшихся главной.
if (photoToDelete.isProfilePhoto && user.photos.length > 0) {
user.photos[0].isProfilePhoto = true;
}
await user.save();
const updatedUser = await User.findById(req.user._id).lean();
res.status(200).json({
message: 'Фотография успешно удалена.',
photos: updatedUser.photos.map(p => ({_id: p._id, url: p.url, public_id: p.public_id, isProfilePhoto: p.isProfilePhoto }))
});
} catch (error) {
console.error('[USER_CTRL] Ошибка при удалении фотографии:', error.message);
next(error);
}
};
module.exports = {
updateUserProfile,
getUsersForSwiping,
uploadUserProfilePhoto,
setMainPhoto, // <--- Добавлено
deletePhoto, // <--- Добавлено
// getUserById,
};

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { updateUserProfile, getUsersForSwiping, uploadUserProfilePhoto } = require('../controllers/userController');
const { updateUserProfile, getUsersForSwiping, uploadUserProfilePhoto, setMainPhoto, deletePhoto } = require('../controllers/userController');
const { protect } = require('../middleware/authMiddleware'); // Нам нужен protect для защиты маршрута
const multer = require('multer'); // 1. Импортируем multer
const path = require('path'); // Может понадобиться для фильтрации файлов
@ -39,6 +39,8 @@ router.get('/suggestions', protect, getUsersForSwiping); // <--- НОВЫЙ МА
// Маршрут для загрузки фотографии профиля
// POST /api/users/profile/photo
router.post('/profile/photo', protect, upload.single('profilePhoto'), uploadUserProfilePhoto);
router.put('/profile/photo/:photoId/set-main', protect, setMainPhoto); // <--- НОВЫЙ МАРШРУТ
router.delete('/profile/photo/:photoId', protect, deletePhoto); // <--- НОВЫЙ МАРШРУТ
// Маршрут для получения профиля по ID (например, для просмотра чужих профилей, если это нужно)
// GET /api/users/:id

45
package-lock.json generated
View File

@ -12,7 +12,9 @@
"axios": "^1.9.0",
"bcryptjs": "^3.0.2",
"bootstrap": "^5.3.6",
"browser-image-compression": "^2.0.2",
"cloudinary": "^2.6.1",
"compressorjs": "^1.2.1",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
@ -2816,6 +2818,12 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/blueimp-canvas-to-blob": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz",
"integrity": "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@ -2866,6 +2874,15 @@
"concat-map": "0.0.1"
}
},
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": {
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
@ -3088,6 +3105,16 @@
"node": ">=4.0.0"
}
},
"node_modules/compressorjs": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/compressorjs/-/compressorjs-1.2.1.tgz",
"integrity": "sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ==",
"license": "MIT",
"dependencies": {
"blueimp-canvas-to-blob": "^3.29.0",
"is-blob": "^2.1.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -4343,6 +4370,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-blob": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz",
"integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-boolean-object": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@ -6683,6 +6722,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -13,7 +13,9 @@
"axios": "^1.9.0",
"bcryptjs": "^3.0.2",
"bootstrap": "^5.3.6",
"browser-image-compression": "^2.0.2",
"cloudinary": "^2.6.1",
"compressorjs": "^1.2.1",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",

View File

@ -37,10 +37,10 @@
<hr class="my-4">
<h4>Загрузить фото профиля</h4>
<form @submit.prevent="handlePhotoUpload" class="mt-3">
<form @submit.prevent="handleMultiplePhotoUpload" class="mt-3">
<div class="mb-3">
<label for="profilePhoto" class="form-label">Выберите фото:</label>
<input type="file" class="form-control" id="profilePhoto" @change="onFileSelected" accept="image/*" :disabled="photoLoading">
<label for="profilePhotos" class="form-label">Выберите фото:</label>
<input type="file" class="form-control" id="profilePhotos" @change="onFilesSelected" accept="image/*" multiple :disabled="photoLoading">
</div>
<div v-if="previewUrl" class="mb-3">
@ -51,7 +51,11 @@
<div v-if="photoUploadSuccessMessage" class="alert alert-success">{{ photoUploadSuccessMessage }}</div>
<div v-if="photoUploadErrorMessage" class="alert alert-danger">{{ photoUploadErrorMessage }}</div>
<button type="submit" class="btn btn-secondary" :disabled="photoLoading || !selectedFile">
<div v-for="(file, index) in selectedFiles" :key="index" class="upload-message" :class="file.status">
{{ file.message }}
</div>
<button type="submit" class="btn btn-secondary" :disabled="photoLoading || selectedFiles.length === 0">
<span v-if="photoLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ photoLoading ? 'Загрузка...' : 'Загрузить фото' }}
</button>
@ -63,6 +67,7 @@
import { ref, onMounted, watch } from 'vue';
import { useAuth } from '@/auth';
import api from '@/services/api';
import imageCompression from 'browser-image-compression'; // Импортируем библиотеку
const { user, fetchUser } = useAuth();
@ -80,9 +85,13 @@
const photoLoading = ref(false);
const photoUploadErrorMessage = ref('');
const photoUploadSuccessMessage = ref('');
const selectedFile = ref(null);
const previewUrl = ref(null);
const selectedFile = ref(null); // Это будет использоваться для одиночной загрузки, если мы решим ее оставить
const previewUrl = ref(null); // И это
// Новые refs для множественной загрузки
const selectedFiles = ref([]); // Массив выбранных файлов
const photoUploadMessages = ref([]); // Массив сообщений о статусе загрузки каждого файла
const initializeFormData = (currentUser) => {
console.log('[EditProfileForm] Инициализация формы с данными:', currentUser);
if (currentUser) {
@ -90,6 +99,7 @@
formData.value.bio = currentUser.bio || '';
formData.value.dateOfBirth = currentUser.dateOfBirth ? new Date(currentUser.dateOfBirth).toISOString().split('T')[0] : '';
formData.value.gender = currentUser.gender || '';
// Не очищаем photoUploadMessages здесь, чтобы пользователь видел результаты предыдущих загрузок в сессии
}
};
@ -137,61 +147,101 @@
}
};
const onFileSelected = (event) => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file;
// Создание URL для предпросмотра
const reader = new FileReader();
reader.onload = (e) => {
previewUrl.value = e.target.result;
};
reader.readAsDataURL(file);
photoUploadErrorMessage.value = ''; // Сброс ошибки при выборе нового файла
photoUploadSuccessMessage.value = ''; // Сброс сообщения об успехе
} else {
// Вместо onFileSelected для одного файла, создадим onFilesSelected для нескольких
const onFilesSelected = (event) => {
const files = event.target.files;
if (files && files.length > 0) {
selectedFiles.value = Array.from(files); // Сохраняем массив файлов
photoUploadMessages.value = selectedFiles.value.map(file => ({ // Инициализируем сообщения для каждого файла
name: file.name,
status: 'pending', // pending, uploading, success, error
message: 'Ожидает загрузки...'
}));
// Очищаем старые одиночные состояния, если они были
selectedFile.value = null;
previewUrl.value = null;
photoUploadErrorMessage.value = '';
photoUploadSuccessMessage.value = '';
} else {
selectedFiles.value = [];
photoUploadMessages.value = [];
}
};
const handlePhotoUpload = async () => {
if (!selectedFile.value) {
photoUploadErrorMessage.value = 'Пожалуйста, выберите файл для загрузки.';
// Обновляем handlePhotoUpload для загрузки нескольких файлов последовательно
const handleMultiplePhotoUpload = async () => {
if (selectedFiles.value.length === 0) {
photoUploadErrorMessage.value = 'Пожалуйста, выберите файлы для загрузки.';
// Очистим индивидуальные сообщения, если они были
photoUploadMessages.value.forEach(msg => {
if (msg.status === 'pending') msg.status = 'cancelled';
});
return;
}
console.log('[EditProfileForm] Загрузка фото...');
photoLoading.value = true;
photoUploadErrorMessage.value = '';
photoUploadSuccessMessage.value = '';
const fd = new FormData();
fd.append('profilePhoto', selectedFile.value, selectedFile.value.name);
photoLoading.value = true; // Общий индикатор загрузки
photoUploadErrorMessage.value = ''; // Сбрасываем общую ошибку
try {
console.log('[EditProfileForm] Отправка фото на сервер:', selectedFile.value.name);
const response = await api.uploadUserProfilePhoto(fd); // Предполагается, что такой метод есть в api.js
console.log('[EditProfileForm] Ответ от сервера (фото):', response.data);
for (let i = 0; i < selectedFiles.value.length; i++) {
const originalFile = selectedFiles.value[i];
const fileMessageIndex = photoUploadMessages.value.findIndex(m => m.name === originalFile.name && m.status !== 'success');
if (fileMessageIndex === -1) continue;
photoUploadSuccessMessage.value = response.data.message || 'Фото успешно загружено!';
selectedFile.value = null;
previewUrl.value = null;
// Очищаем поле выбора файла визуально (браузер может не сбросить его сам)
const photoInput = document.getElementById('profilePhoto');
if (photoInput) {
photoInput.value = '';
photoUploadMessages.value[fileMessageIndex].status = 'uploading';
photoUploadMessages.value[fileMessageIndex].message = 'Сжатие и загрузка...';
try {
// Опции сжатия
const options = {
maxSizeMB: 1, // Максимальный размер файла в MB
maxWidthOrHeight: 1920, // Максимальная ширина или высота
useWebWorker: true, // Использовать Web Worker для лучшей производительности
// Дополнительные опции можно найти в документации библиотеки
// Например, initialQuality для JPEG
}
console.log(`[EditProfileForm] Сжатие файла ${originalFile.name}...`);
const compressedFile = await imageCompression(originalFile, options);
console.log(`[EditProfileForm] Файл ${originalFile.name} сжат. Оригинальный размер: ${(originalFile.size / 1024 / 1024).toFixed(2)} MB, Новый размер: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
const fd = new FormData();
// Важно: сервер ожидает имя файла, поэтому передаем его как третий аргумент
fd.append('profilePhoto', compressedFile, originalFile.name);
console.log(`[EditProfileForm] Отправка файла ${originalFile.name} (сжатого) на сервер...`);
const response = await api.uploadUserProfilePhoto(fd);
console.log(`[EditProfileForm] Ответ от сервера (фото ${originalFile.name}):`, response.data);
photoUploadMessages.value[fileMessageIndex].status = 'success';
photoUploadMessages.value[fileMessageIndex].message = response.data.message || 'Фото успешно загружено!';
await fetchUser();
console.log(`[EditProfileForm] Данные пользователя обновлены (фото ${originalFile.name})`);
} catch (err) {
console.error(`[EditProfileForm] Ошибка при сжатии или загрузке фото ${originalFile.name}:`, err);
photoUploadMessages.value[fileMessageIndex].status = 'error';
// Проверяем, является ли ошибка ошибкой сжатия
if (err instanceof Error && err.message.includes('compression')) {
photoUploadMessages.value[fileMessageIndex].message = `Ошибка при сжатии фото ${originalFile.name}.`;
} else {
photoUploadMessages.value[fileMessageIndex].message = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: `Ошибка при загрузке фото ${originalFile.name}.`;
}
}
await fetchUser(); // Обновляем данные пользователя, включая массив photos
console.log('[EditProfileForm] Данные пользователя обновлены (фото)');
} catch (err) {
console.error('[EditProfileForm] Ошибка при загрузке фото:', err);
photoUploadErrorMessage.value = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Ошибка при загрузке фото.';
} finally {
photoLoading.value = false;
}
// После завершения всех загрузок
selectedFiles.value = []; // Очищаем выбранные файлы
// Не очищаем photoInput.value здесь, т.к. input type="file" multiple сам управляет списком
// Можно сбросить значение инпута, чтобы пользователь мог выбрать те же файлы снова, если захочет
const photoInput = document.getElementById('profilePhotos'); // Убедимся, что ID правильный
if (photoInput) {
photoInput.value = '';
}
photoLoading.value = false; // Выключаем общий индикатор
};
</script>
@ -208,4 +258,16 @@
background-color: #fff;
border-radius: 0.25rem;
}
.upload-message {
font-size: 0.9em;
}
.upload-message.success {
color: green;
}
.upload-message.error {
color: red;
}
.upload-message.pending, .upload-message.uploading {
color: orange;
}
</style>

View File

@ -88,6 +88,13 @@ export default {
},
});
},
// Новые методы для управления фотографиями
setMainPhoto(photoId) {
return apiClient.put(`/users/profile/photo/${photoId}/set-main`);
},
deletePhoto(photoId) {
return apiClient.delete(`/users/profile/photo/${photoId}`);
},
getUserConversations() {
return apiClient.get('/conversations'); // GET /api/conversations
},

View File

@ -25,9 +25,30 @@
<div v-if="profileData && profileData.photos && profileData.photos.length > 0 && !initialLoading" class="card mb-4">
<div class="card-body">
<h5 class="card-title">Мои фотографии</h5>
<p v-if="photoActionError" class="alert alert-danger">{{ photoActionError }}</p>
<p v-if="photoActionSuccess" class="alert alert-success">{{ photoActionSuccess }}</p>
<div class="row">
<div v-for="photo in profileData.photos" :key="photo.public_id" class="col-md-4 mb-3">
<img :src="photo.url" class="img-fluid img-thumbnail" alt="Фото пользователя">
<div v-for="photo in profileData.photos" :key="photo.public_id || photo._id" class="col-md-4 mb-3">
<div class="photo-container position-relative">
<img :src="photo.url" class="img-fluid img-thumbnail" alt="Фото пользователя">
<div class="photo-actions position-absolute bottom-0 start-0 end-0 p-2 bg-dark bg-opacity-50">
<button
@click="setAsMainPhoto(photo._id)"
class="btn btn-sm btn-light me-1"
:disabled="photo.isProfilePhoto || photoActionLoading"
title="Сделать главной">
<i class="bi" :class="{'bi-star-fill text-warning': photo.isProfilePhoto, 'bi-star': !photo.isProfilePhoto}"></i>
</button>
<button
@click="confirmDeletePhoto(photo._id)"
class="btn btn-sm btn-danger"
:disabled="photoActionLoading"
title="Удалить фото">
<i class="bi bi-trash"></i>
</button>
</div>
<span v-if="photo.isProfilePhoto" class="badge bg-primary position-absolute top-0 start-0 m-1">Главная</span>
</div>
</div>
</div>
</div>
@ -44,6 +65,28 @@
<p>Не удалось загрузить данные профиля. Возможно, вы не авторизованы или произошла ошибка.</p>
<router-link to="/login" class="btn btn-primary">Войти</router-link>
</div>
<!-- Модальное окно подтверждения удаления -->
<div class="modal fade" id="deletePhotoModal" tabindex="-1" aria-labelledby="deletePhotoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePhotoModalLabel">Подтвердить удаление</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Вы уверены, что хотите удалить эту фотографию?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" @click="executeDeletePhoto" :disabled="photoActionLoading">
<span v-if="photoActionLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@ -51,15 +94,24 @@
import { ref, onMounted, computed, watch } from 'vue'; // Добавлен watch
import { useAuth } from '@/auth';
import api from '@/services/api';
import router from '@/router';
// import router from '@/router'; // Закомментировано, так как router не используется напрямую
import EditProfileForm from '@/components/EditProfileForm.vue';
import { Modal } from 'bootstrap'; // Импортируем Modal
const { isAuthenticated, user: authUserFromStore, token, fetchUser } = useAuth(); // Добавлен fetchUser
const { isAuthenticated, user: authUserFromStore, token, fetchUser } = useAuth();
const profileData = ref(null);
const loading = ref(true);
const initialLoading = ref(true);
const error = ref('');
// Для управления фото
const photoActionLoading = ref(false);
const photoActionError = ref('');
const photoActionSuccess = ref('');
const photoToDeleteId = ref(null);
let deleteModalInstance = null;
const fetchProfileDataLocal = async () => {
loading.value = true;
error.value = '';
@ -72,10 +124,7 @@ const fetchProfileDataLocal = async () => {
}
try {
// Вместо прямого вызова api.getMe(), используем fetchUser из useAuth,
// который обновит authUserFromStore, за которым мы будем следить.
await fetchUser();
// profileData.value будет обновляться через watch
} catch (err) {
console.error('[ProfileView] Ошибка при вызове fetchUser:', err);
error.value = (err.response && err.response.data && err.response.data.message)
@ -87,27 +136,26 @@ const fetchProfileDataLocal = async () => {
}
};
// Следим за изменениями authUserFromStore и обновляем profileData
watch(authUserFromStore, (newUser) => {
if (newUser) {
profileData.value = { ...newUser }; // Клонируем, чтобы избежать прямой мутации, если это необходимо
profileData.value = { ...newUser };
console.log('[ProfileView] Данные профиля обновлены из authUserFromStore:', profileData.value);
}
}, { immediate: true, deep: true }); // immediate: true для инициализации при монтировании
}, { immediate: true, deep: true });
onMounted(() => {
// Если authUserFromStore уже содержит данные (например, после логина),
// они будут установлены через watch immediate: true.
// Если нет, или мы хотим принудительно обновить, вызываем fetchProfileDataLocal.
onMounted(async () => {
if (!authUserFromStore.value || Object.keys(authUserFromStore.value).length === 0) {
fetchProfileDataLocal();
await fetchProfileDataLocal();
} else {
// Данные уже есть, initialLoading можно установить в false
// loading также, так как данные уже есть
profileData.value = { ...authUserFromStore.value };
loading.value = false;
initialLoading.value = false;
console.log('[ProfileView] Данные профиля уже были в хранилище:', profileData.value);
profileData.value = { ...authUserFromStore.value };
loading.value = false;
initialLoading.value = false;
console.log('[ProfileView] Данные профиля уже были в хранилище:', profileData.value);
}
// Инициализация модального окна
const modalElement = document.getElementById('deletePhotoModal');
if (modalElement) {
deleteModalInstance = new Modal(modalElement);
}
});
@ -116,6 +164,51 @@ const formatDate = (dateString) => {
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return new Date(dateString).toLocaleDateString(undefined, options);
};
const setAsMainPhoto = async (photoId) => {
photoActionLoading.value = true;
photoActionError.value = '';
photoActionSuccess.value = '';
try {
const response = await api.setMainPhoto(photoId);
await fetchUser(); // Обновляем данные пользователя (и profileData через watch)
photoActionSuccess.value = response.data.message || 'Главное фото обновлено.';
} catch (err) {
console.error('[ProfileView] Ошибка при установке главного фото:', err);
photoActionError.value = err.response?.data?.message || 'Не удалось установить главное фото.';
} finally {
photoActionLoading.value = false;
}
};
const confirmDeletePhoto = (photoId) => {
photoToDeleteId.value = photoId;
if (deleteModalInstance) {
deleteModalInstance.show();
}
};
const executeDeletePhoto = async () => {
if (!photoToDeleteId.value) return;
photoActionLoading.value = true;
photoActionError.value = '';
photoActionSuccess.value = '';
try {
const response = await api.deletePhoto(photoToDeleteId.value);
await fetchUser(); // Обновляем данные пользователя
photoActionSuccess.value = response.data.message || 'Фотография удалена.';
} catch (err) {
console.error('[ProfileView] Ошибка при удалении фото:', err);
photoActionError.value = err.response?.data?.message || 'Не удалось удалить фотографию.';
} finally {
photoActionLoading.value = false;
if (deleteModalInstance) {
deleteModalInstance.hide();
}
photoToDeleteId.value = null;
}
};
</script>
<style scoped>
@ -132,7 +225,14 @@ const formatDate = (dateString) => {
padding: 0.25rem;
background-color: #fff;
border-radius: 0.25rem;
max-width: 100%; /* Убедимся, что изображение не выходит за рамки колонки */
height: auto; /* Сохраняем пропорции */
max-width: 100%;
height: auto;
}
.photo-container .photo-actions {
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.photo-container:hover .photo-actions {
opacity: 1;
}
</style>

View File

@ -21,19 +21,44 @@
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm user-card">
<!-- === ОТОБРАЖЕНИЕ ФОТО === -->
<div class="image-placeholder-container">
<img
v-if="currentUserToSwipe.mainPhotoUrl"
:src="currentUserToSwipe.mainPhotoUrl"
class="card-img-top user-photo"
:alt="'Фото ' + currentUserToSwipe.name"
@error="onImageError($event, currentUserToSwipe)"
/>
<!-- Заглушка, если фото не загрузилось ИЛИ если mainPhotoUrl нет -->
<div v-else class="no-photo-placeholder d-flex align-items-center justify-content-center">
<i class="bi bi-person-fill"></i>
<!-- === ОТОБРАЖЕНИЕ ФОТО (КАРУСЕЛЬ) === -->
<div v-if="currentUserToSwipe.photos && currentUserToSwipe.photos.length > 0"
:id="`carouselUserPhotos-${currentUserToSwipe._id}`"
class="carousel slide"
data-bs-ride="false">
<div class="carousel-inner">
<div v-for="(photo, index) in currentUserToSwipe.photos"
:key="photo.public_id || photo._id"
class="carousel-item"
:class="{ active: photo.isProfilePhoto || (!currentUserToSwipe.photos.some(p => p.isProfilePhoto) && index === 0) }">
<img :src="photo.url"
class="d-block w-100 user-photo"
:alt="'Фото ' + currentUserToSwipe.name + ' ' + (index + 1)"
@error="onImageError($event, currentUserToSwipe, photo._id)">
</div>
</div>
<button v-if="currentUserToSwipe.photos.length > 1" class="carousel-control-prev" type="button" :data-bs-target="`#carouselUserPhotos-${currentUserToSwipe._id}`" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button v-if="currentUserToSwipe.photos.length > 1" class="carousel-control-next" type="button" :data-bs-target="`#carouselUserPhotos-${currentUserToSwipe._id}`" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
<div class="carousel-indicators" v-if="currentUserToSwipe.photos.length > 1">
<button v-for="(photo, index) in currentUserToSwipe.photos"
:key="`indicator-${photo._id}`"
type="button"
:data-bs-target="`#carouselUserPhotos-${currentUserToSwipe._id}`"
:data-bs-slide-to="index"
:class="{ active: photo.isProfilePhoto || (!currentUserToSwipe.photos.some(p => p.isProfilePhoto) && index === 0) }"
:aria-current="photo.isProfilePhoto || (!currentUserToSwipe.photos.some(p => p.isProfilePhoto) && index === 0) ? 'true' : 'false'"
:aria-label="`Slide ${index + 1}`"></button>
</div>
</div>
<!-- Заглушка, если фото не загрузилось ИЛИ если mainPhotoUrl нет -->
<div v-else class="no-photo-placeholder d-flex align-items-center justify-content-center">
<i class="bi bi-person-fill"></i>
</div>
<!-- ========================== -->
@ -197,15 +222,25 @@ const formatGender = (gender) => {
return ''; // Возвращаем пустую строку, если пол не указан, чтобы не было "Пол не указан"
};
const onImageError = (event, userOnError) => {
console.warn("Не удалось загрузить изображение:", event.target.src, "для пользователя:", userOnError.name);
// Найдем пользователя в suggestions и установим mainPhotoUrl в null
// чтобы v-else сработал корректно и реактивно
const onImageError = (event, userOnError, photoIdOnError) => {
console.warn("Не удалось загрузить изображение:", event.target.src, "для пользователя:", userOnError.name, "фото ID:", photoIdOnError);
const userInSuggestions = suggestions.value.find(u => u._id === userOnError._id);
if (userInSuggestions) {
userInSuggestions.mainPhotoUrl = null;
if (userInSuggestions && userInSuggestions.photos) {
const photoIndex = userInSuggestions.photos.findIndex(p => p._id === photoIdOnError);
if (photoIndex > -1) {
// Вместо удаления можно установить флаг ошибки или заменить URL на заглушку
// userInSuggestions.photos.splice(photoIndex, 1);
// Если просто убрать фото из массива, и оно было единственным, карусель исчезнет.
// Лучше обработать это в UI, показав заглушку для этого конкретного слайда или скрыв слайд.
// Пока просто выводим ошибку и позволяем Bootstrap обработать битую картинку (покажет alt текст).
event.target.style.display = 'none'; // Скрываем битое изображение
// Можно добавить заглушку на место битого изображения, если это один слайд из многих
}
// Если после ошибки не осталось фото, показываем общую заглушку
if (!userInSuggestions.photos.some(p => p.url)) {
userInSuggestions.mainPhotoUrl = null; // Это для старой логики с одним фото, нужно будет обновить
}
}
// event.target.style.display = 'none'; // Можно убрать, v-else должен справиться
};
</script>
@ -292,6 +327,10 @@ const onImageError = (event, userOnError) => {
height: 100%;
object-fit: cover;
}
.carousel-item img {
height: 400px; /* Фиксированная высота для фото в карусели */
object-fit: cover;
}
.no-photo-placeholder {
border-bottom: 1px solid #dee2e6; /* Граница, как у card-img-top */
}