Reflex/src/views/UserProfileView.vue

1060 lines
23 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-user-profile-view">
<!-- Фиксированный хедер -->
<div class="profile-header">
<button class="back-btn" @click="goBack">
<i class="bi-chevron-left"></i>
</button>
<h2 class="section-title">{{ user?.name || 'Профиль' }}</h2>
<div class="header-spacer"></div>
</div>
<!-- Основное содержимое -->
<main class="profile-content">
<!-- Loading State -->
<div v-if="loading" 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="error-card">
<i class="bi-exclamation-triangle"></i>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="retry-btn" @click="loadUser">Попробовать снова</button>
</div>
</div>
<!-- Main Profile Content -->
<div v-if="user && !loading" class="user-profile">
<!-- Карточка с фотографиями -->
<div class="photo-card">
<!-- Photo Carousel -->
<div class="photo-carousel">
<!-- Photos Array -->
<div v-if="user.photos && user.photos.length > 0" class="carousel-wrapper">
<div class="carousel-inner">
<div
v-for="(photo, photoIndex) in user.photos"
:key="photo.public_id || photo._id"
class="carousel-slide"
:class="{ 'active': currentPhotoIndex === photoIndex }"
>
<img
:src="photo.url"
class="photo-image"
:alt="'Фото ' + user.name"
@error="onImageError"
/>
</div>
</div>
<!-- Photo Navigation Dots -->
<div class="carousel-dots" v-if="user.photos.length > 1">
<span
v-for="(photo, photoIndex) in user.photos"
:key="`dot-${photoIndex}`"
class="dot"
:class="{ 'active': currentPhotoIndex === photoIndex }"
@click="changePhoto(photoIndex)"
></span>
</div>
<!-- Previous/Next buttons -->
<button
v-if="user.photos.length > 1"
class="carousel-nav prev"
@click="prevPhoto"
>
<i class="bi-chevron-left"></i>
</button>
<button
v-if="user.photos.length > 1"
class="carousel-nav next"
@click="nextPhoto"
>
<i class="bi-chevron-right"></i>
</button>
</div>
<!-- Single Photo -->
<div v-else-if="user.mainPhotoUrl" class="single-photo">
<img
:src="user.mainPhotoUrl"
class="photo-image"
:alt="'Фото ' + user.name"
@error="onImageError"
/>
</div>
<!-- No Photo Placeholder -->
<div v-else class="no-photo">
<i class="bi-person"></i>
</div>
</div>
<!-- User Info Overlay -->
<div class="user-info-overlay">
<h3 class="user-name">{{ user.name }}<span class="user-age" v-if="userAge">, {{ userAge }}</span></h3>
<p class="user-gender">{{ formatGender(user.gender) }}</p>
<p class="user-location" v-if="user.location?.city">
<i class="bi-geo-alt"></i>
{{ user.location.city }}
</p>
</div>
</div>
<!-- Информационная карточка -->
<div class="info-card">
<div class="card-header">
<h3><i class="bi-person-lines-fill"></i> О пользователе</h3>
</div>
<div class="card-content">
<div class="info-grid">
<div class="info-item" v-if="user.bio">
<label>О себе</label>
<p class="bio-text">{{ user.bio }}</p>
</div>
<div class="info-item" v-if="userAge">
<label>Возраст</label>
<span>{{ userAge }} лет</span>
</div>
<div class="info-item" v-if="user.gender">
<label>Пол</label>
<span>{{ formatGender(user.gender) }}</span>
</div>
<div class="info-item" v-if="user.location?.city">
<label>Город</label>
<span>{{ user.location.city }}</span>
</div>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="action-buttons">
<button
class="action-btn pass-btn"
@click="handlePass"
:disabled="actionLoading"
>
<div class="btn-icon">
<i class="bi-x-lg"></i>
</div>
<span>Пропустить</span>
</button>
<button
class="action-btn like-btn"
@click="handleLike"
:disabled="actionLoading"
>
<div class="btn-icon">
<i class="bi-heart-fill"></i>
</div>
<span>Нравится</span>
</button>
<button
class="action-btn report-btn"
@click="openReportModal"
title="Пожаловаться"
>
<div class="btn-icon">
<i class="bi-flag"></i>
</div>
<span>Жалоба</span>
</button>
</div>
</div>
</main>
<!-- Match Notification -->
<transition name="match-popup">
<div v-if="matchOccurred" class="match-popup">
<div class="match-content">
<div class="match-header">
<i class="bi-stars"></i>
<h3>Вы понравились друг другу!</h3>
</div>
<div class="match-actions">
<button class="match-btn secondary" @click="goBack">
Продолжить поиск
</button>
<button class="match-btn primary" @click="goToChats">
Начать общение
</button>
</div>
</div>
</div>
</transition>
<!-- Background Overlay for Match -->
<div
v-if="matchOccurred"
class="overlay"
@click="goBack"
></div>
<!-- Report Modal -->
<ReportModal
:isVisible="showReportModal"
:reportedUserId="user?._id || ''"
:reportedUserName="user?.name || ''"
@close="closeReportModal"
@reported="handleReportSubmitted"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '@/services/api';
import ReportModal from '@/components/ReportModal.vue';
const route = useRoute();
const router = useRouter();
// Reactive data
const user = ref(null);
const loading = ref(true);
const actionLoading = ref(false);
const error = ref('');
const currentPhotoIndex = ref(0);
const matchOccurred = ref(false);
// Для системы жалоб
const showReportModal = ref(false);
// Computed properties
const userAge = computed(() => {
if (!user.value?.dateOfBirth) return null;
const today = new Date();
const birthDate = new Date(user.value.dateOfBirth);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
});
// Methods
const loadUser = async () => {
if (!route.params.userId) {
error.value = 'ID пользователя не указан';
loading.value = false;
return;
}
loading.value = true;
error.value = '';
try {
console.log('[UserProfileView] Загрузка пользователя:', route.params.userId);
const response = await api.getUserById(route.params.userId);
user.value = response.data;
// Записываем просмотр профиля
try {
await api.recordProfileView(route.params.userId, 'profile_link');
console.log('[UserProfileView] Просмотр профиля записан');
} catch (viewError) {
console.warn('[UserProfileView] Не удалось записать просмотр профиля:', viewError);
// Не показываем ошибку пользователю, так как это не критично
}
console.log('[UserProfileView] Пользователь загружен:', user.value);
} catch (err) {
console.error('[UserProfileView] Ошибка при загрузке пользователя:', err);
error.value = err.response?.data?.message || 'Не удалось загрузить профиль пользователя';
} finally {
loading.value = false;
}
};
const changePhoto = (index) => {
currentPhotoIndex.value = index;
};
const nextPhoto = () => {
if (user.value?.photos && user.value.photos.length > 0) {
currentPhotoIndex.value = (currentPhotoIndex.value + 1) % user.value.photos.length;
}
};
const prevPhoto = () => {
if (user.value?.photos && user.value.photos.length > 0) {
currentPhotoIndex.value = (currentPhotoIndex.value - 1 + user.value.photos.length) % user.value.photos.length;
}
};
const handleLike = async () => {
if (!user.value || actionLoading.value) return;
actionLoading.value = true;
try {
console.log('[UserProfileView] Лайк пользователя:', user.value._id);
const response = await api.likeUser(user.value._id);
if (response.data.isMatch) {
matchOccurred.value = true;
console.log('[UserProfileView] Мэтч!');
} else {
goBack();
}
} catch (err) {
console.error('[UserProfileView] Ошибка при лайке:', err);
// Можно добавить уведомление об ошибке
} finally {
actionLoading.value = false;
}
};
const handlePass = async () => {
if (!user.value || actionLoading.value) return;
actionLoading.value = true;
try {
console.log('[UserProfileView] Пропуск пользователя:', user.value._id);
await api.passUser(user.value._id);
goBack();
} catch (err) {
console.error('[UserProfileView] Ошибка при пропуске:', err);
// Даже если произошла ошибка, возвращаемся назад
goBack();
} finally {
actionLoading.value = false;
}
};
const formatGender = (gender) => {
const genderMap = {
'male': 'Мужчина',
'female': 'Женщина',
'other': 'Другой'
};
return genderMap[gender] || '';
};
const goBack = () => {
matchOccurred.value = false;
router.go(-1);
};
const goToChats = () => {
matchOccurred.value = false;
router.push('/chats');
};
const onImageError = (event) => {
console.warn('[UserProfileView] Ошибка загрузки изображения:', event.target.src);
};
// Report modal methods
const openReportModal = () => {
showReportModal.value = true;
};
const closeReportModal = () => {
showReportModal.value = false;
};
const handleReportSubmitted = () => {
// Просто закрываем модальное окно, оно само покажет сообщение об успехе
console.log('[UserProfileView] Жалоба отправлена');
};
// Lifecycle
onMounted(() => {
loadUser();
});
</script>
<style scoped>
/* Основные стили контейнера */
.app-user-profile-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
}
/* Хедер */
.profile-header {
background: rgba(33, 33, 60, 0.9);
backdrop-filter: blur(10px);
padding: 0.8rem 1rem;
color: white;
position: sticky;
top: 0;
z-index: 10;
height: var(--header-height, 56px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0.3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
min-width: 40px;
min-height: 40px;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.section-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
flex: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-spacer {
min-width: 40px;
}
/* Основная область контента */
.profile-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 1rem;
padding-bottom: calc(1rem + var(--nav-height, 60px));
}
/* Состояния загрузки и ошибки */
.loading-section,
.error-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 1rem;
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;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
}
.error-card i {
font-size: 3rem;
margin-bottom: 1rem;
color: #495057;
}
.error-card h3 {
color: #343a40;
margin-bottom: 0.5rem;
}
.retry-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.8rem 2rem;
border-radius: 50px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
}
.retry-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
/* Профиль пользователя */
.user-profile {
max-width: 500px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Карточка с фотографиями */
.photo-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
position: relative;
aspect-ratio: 3/4;
max-height: 70vh;
}
.photo-carousel {
width: 100%;
height: 100%;
position: relative;
}
.carousel-wrapper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.carousel-inner {
width: 100%;
height: 100%;
position: relative;
}
.carousel-slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease;
}
.carousel-slide.active {
opacity: 1;
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Навигация карусели */
.carousel-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.8);
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
transition: all 0.2s ease;
font-size: 1.2rem;
color: #333;
}
.carousel-nav:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-50%) scale(1.1);
}
.carousel-nav.prev {
left: 15px;
}
.carousel-nav.next {
right: 15px;
}
.carousel-dots {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
z-index: 5;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.3s ease;
}
.dot.active {
background-color: white;
transform: scale(1.2);
}
.dot:hover {
background-color: rgba(255, 255, 255, 0.8);
}
/* Заполнители для фото */
.single-photo {
width: 100%;
height: 100%;
}
.no-photo {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e9ecef;
}
.no-photo i {
font-size: 4rem;
color: #adb5bd;
}
/* Информация о пользователе */
.user-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: white;
padding: 20px;
text-align: left;
}
.user-name {
margin: 0 0 5px 0;
font-size: 1.6rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.user-age {
font-weight: 400;
}
.user-gender,
.user-location {
margin: 3px 0;
opacity: 0.9;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Информационная карточка */
.info-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.card-header {
background: rgba(248, 249, 250, 0.8);
padding: 1rem 1.25rem;
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 {
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item label {
font-size: 0.8rem;
font-weight: 500;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
font-size: 0.95rem;
color: #333;
}
.bio-text {
line-height: 1.6;
color: #333;
font-size: 0.95rem;
}
/* Кнопки действий */
.action-buttons {
display: flex;
gap: 1rem;
padding: 1rem 0;
}
.action-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border: none;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
font-weight: 500;
}
.action-btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
}
.pass-btn .btn-icon {
background: linear-gradient(45deg, #dc3545, #c82333);
color: white;
}
.like-btn .btn-icon {
background: linear-gradient(45deg, #28a745, #20c997);
color: white;
}
.report-btn .btn-icon {
background: linear-gradient(45deg, #007bff, #0056b3);
color: white;
}
.pass-btn {
color: #dc3545;
}
.like-btn {
color: #28a745;
}
.report-btn {
color: #007bff;
}
/* Всплывающее окно совпадения */
.match-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 20px;
padding: 1.8rem;
width: 90%;
max-width: 360px;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
text-align: center;
z-index: 1000;
}
.match-header {
margin-bottom: 1.5rem;
}
.match-header i {
font-size: 3rem;
color: #ff6b6b;
margin-bottom: 1rem;
}
.match-header h3 {
font-size: 1.4rem;
color: #333;
margin: 0;
}
.match-actions {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.match-btn {
padding: 0.8rem 1rem;
border-radius: 50px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
}
.match-btn.primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
}
.match-btn.secondary {
background: #f1f3f5;
color: #495057;
}
.match-btn:hover {
transform: translateY(-2px);
}
/* Затемняющий оверлей */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 999;
}
/* Анимации для всплывающего окна */
.match-popup-enter-active, .match-popup-leave-active {
transition: all 0.3s ease;
}
.match-popup-enter-from, .match-popup-leave-to {
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
}
/* Адаптивные стили */
@media (min-width: 768px) {
.profile-content {
padding: 1.5rem;
}
.user-profile {
max-width: 600px;
}
.photo-card {
max-height: 75vh;
}
.action-buttons {
flex-direction: row;
gap: 1.5rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.info-item:first-child {
grid-column: 1 / -1;
}
}
@media (max-width: 480px) {
.profile-content {
padding: 0.75rem;
}
.section-title {
font-size: 1.1rem;
}
.user-name {
font-size: 1.4rem;
}
.carousel-nav {
width: 35px;
height: 35px;
font-size: 1.1rem;
}
.action-btn {
padding: 0.8rem;
}
.btn-icon {
width: 45px;
height: 45px;
font-size: 1.6rem;
}
.card-header,
.card-content {
padding: 1rem;
}
}
@media (max-height: 600px) {
.photo-card {
max-height: 60vh;
}
.action-buttons {
padding: 0.8rem 0;
}
}
/* Ландшафтная ориентация */
@media (max-height: 450px) and (orientation: landscape) {
.profile-content {
display: flex;
gap: 1rem;
padding: 0.5rem;
}
.photo-card {
flex: 1;
max-height: none;
height: calc(100vh - 120px);
}
.info-card {
flex: 1;
overflow-y: auto;
}
.action-buttons {
position: fixed;
bottom: calc(var(--nav-height, 60px) + 10px);
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 0.5rem 1rem;
border-radius: 25px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
width: auto;
min-width: 300px;
}
.action-btn {
flex-direction: row;
padding: 0.6rem 1rem;
gap: 0.5rem;
}
.btn-icon {
width: 35px;
height: 35px;
font-size: 1.4rem;
}
}
/* Учитываем Safe Area для iPhone X+ */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.profile-content {
padding-bottom: calc(1rem + var(--nav-height, 60px) + env(safe-area-inset-bottom, 0px));
}
@media (max-height: 450px) and (orientation: landscape) {
.action-buttons {
bottom: calc(var(--nav-height, 60px) + env(safe-area-inset-bottom, 0px) + 10px);
}
}
}
/* Темная тема адаптация */
@media (prefers-color-scheme: dark) {
.info-card {
background: rgba(255, 255, 255, 0.95);
}
.card-header {
background: rgba(248, 249, 250, 0.9);
}
.action-btn {
background: rgba(255, 255, 255, 0.95);
}
}
</style>