Reflex/src/views/SwipeView.vue
2025-05-25 22:14:38 +07:00

1412 lines
40 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-swipe-view">
<!-- Основное содержимое -->
<main class="swipe-content no-scroll">
<!-- 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="fetchSuggestions">Попробовать снова</button>
</div>
</div>
<!-- No Suggestions State -->
<div v-if="!loading && suggestions.length === 0 && !error" class="empty-section">
<div class="empty-card">
<i class="bi-person-x"></i>
<h3>Нет анкет</h3>
<p>Пока нет доступных анкет. Попробуй зайти позже!</p>
</div>
</div>
<!-- Main Card Swiping Area -->
<div v-if="!loading && suggestions.length > 0 && !error" class="swipe-area" ref="swipeArea">
<!-- Card Stack -->
<div class="card-stack">
<div
v-for="(user, index) in visibleUsers"
:key="user._id"
class="swipe-card"
:class="{
'active': index === 0,
'next': index === 1,
'swiping-left': swipeDirection === 'left' && index === 0,
'swiping-right': swipeDirection === 'right' && index === 0
}"
:style="index === 0 ? cardStyle : {}"
@touchstart="index === 0 ? startTouch($event) : null"
@touchmove="index === 0 ? moveTouch($event) : null"
@touchend="index === 0 ? endTouch($event) : null"
@mousedown="index === 0 ? startDrag($event) : null"
@click="index === 0 ? handleCardClick($event, user) : null"
>
<!-- Photo Section -->
<div class="card-photo-section">
<!-- 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[user._id] === photoIndex }"
>
<img
:src="photo.url"
class="photo-image"
:alt="'Фото ' + user.name"
@error="onImageError($event, user, photo, 'carousel')"
/>
</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-${photo._id}`"
class="dot"
:class="{ 'active': currentPhotoIndex[user._id] === photoIndex }"
@click.stop="changePhoto(user._id, photoIndex)"
></span>
</div>
<!-- Previous/Next buttons -->
<button
v-if="user.photos.length > 1"
class="carousel-nav prev"
@click.stop="prevPhoto(user._id)"
>
<i class="bi-chevron-left"></i>
</button>
<button
v-if="user.photos.length > 1"
class="carousel-nav next"
@click.stop="nextPhoto(user._id)"
>
<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($event, user, { url: user.mainPhotoUrl }, 'mainPhoto')"
/>
</div>
<!-- No Photo Placeholder -->
<div v-else class="no-photo">
<i class="bi-person"></i>
</div>
</div>
<!-- Action Indicators -->
<div class="swipe-indicators">
<div
class="like-indicator"
:class="{ 'visible': swipeDirection === 'right' && index === 0 }"
>
<i class="bi-heart-fill"></i>
</div>
<div
class="pass-indicator"
:class="{ 'visible': swipeDirection === 'left' && index === 0 }"
>
<i class="bi-x-lg"></i>
</div>
</div>
<!-- User Info Overlay -->
<div class="user-info-overlay">
<h3 class="user-name">{{ user.name }}<span class="user-age">, {{ user.age || '?' }}</span></h3>
<p class="user-gender">{{ formatGender(user.gender) }}</p>
</div>
</div>
<!-- User Details Section -->
<div class="card-details-section">
<div class="bio-container">
<p class="bio-text">{{ user.bio || 'Нет описания' }}</p>
</div>
</div>
</div>
</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="continueSearching">
Продолжить поиск
</button>
<button class="match-btn primary" @click="goToChats">
Начать общение
</button>
</div>
</div>
</div>
</transition>
<!-- Background Overlay for Match -->
<div
v-if="matchOccurred"
class="overlay"
@click="continueSearching"
></div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, reactive, watch, onUnmounted, nextTick } from 'vue';
import api from '@/services/api';
import { useAuth } from '@/auth';
import router from '@/router';
const { isAuthenticated, logout } = useAuth();
// Основные данные
const suggestions = ref([]);
const currentIndex = ref(0);
const loading = ref(true);
const actionLoading = ref(false);
const error = ref('');
const matchOccurred = ref(false);
const matchedUserId = ref(null);
const swipeArea = ref(null);
// Для карусели фото
const currentPhotoIndex = reactive({});
// Для свайпа
const swipeDirection = ref(null);
const swipeThreshold = 80; // Минимальное расстояние для срабатывания свайпа
const cardStyle = ref({});
const swipeAngle = 12; // Угол наклона карты при свайпе (в градусах)
const isPanning = ref(false);
// Для touch событий
const touchStartX = ref(0);
const touchStartY = ref(0);
const touchEndX = ref(0);
const touchEndY = ref(0);
// Для mouse событий
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragOffset = ref({ x: 0, y: 0 });
const dragStartTime = ref(0); // Добавляем время начала перетаскивания
const hasActuallyMoved = ref(false); // Флаг, что было реальное движение
// Вычисляемые свойства
const currentUserToSwipe = computed(() => {
if (suggestions.value.length > 0 && currentIndex.value < suggestions.value.length) {
return suggestions.value[currentIndex.value];
}
return null;
});
const visibleUsers = computed(() => {
// Показываем текущего пользователя и следующего (для эффекта стека)
const users = [];
if (currentUserToSwipe.value) {
users.push(currentUserToSwipe.value);
if (currentIndex.value + 1 < suggestions.value.length) {
users.push(suggestions.value[currentIndex.value + 1]);
}
}
return users;
});
// Методы управления каруселью фото
const initPhotoIndices = () => {
suggestions.value.forEach(user => {
if (user && user._id) {
if (!currentPhotoIndex[user._id]) {
// Определяем начальное фото (основное или первое в списке)
if (user.photos && user.photos.length > 0) {
const profilePhotoIndex = user.photos.findIndex(p => p.isProfilePhoto);
currentPhotoIndex[user._id] = profilePhotoIndex >= 0 ? profilePhotoIndex : 0;
} else {
currentPhotoIndex[user._id] = 0;
}
}
}
});
};
const changePhoto = (userId, index) => {
if (isPanning.value) return; // Не меняем фото во время свайпа
currentPhotoIndex[userId] = index;
};
const nextPhoto = (userId) => {
if (isPanning.value) return;
const user = suggestions.value.find(u => u._id === userId);
if (user && user.photos && user.photos.length > 0) {
currentPhotoIndex[userId] = (currentPhotoIndex[userId] + 1) % user.photos.length;
}
};
const prevPhoto = (userId) => {
if (isPanning.value) return;
const user = suggestions.value.find(u => u._id === userId);
if (user && user.photos && user.photos.length > 0) {
currentPhotoIndex[userId] = (currentPhotoIndex[userId] - 1 + user.photos.length) % user.photos.length;
}
};
// Обработчики touch событий
const startTouch = (event) => {
// Проверяем, что это действительно touch-событие, а не эмуляция мыши
if (event.touches && event.touches.length === 1) {
isPanning.value = true;
touchStartX.value = event.touches[0].clientX;
touchStartY.value = event.touches[0].clientY;
// Сбросить конечные точки и время
touchEndX.value = 0;
touchEndY.value = 0;
dragStartTime.value = 0; // Сбрасываем время для touch событий
hasActuallyMoved.value = false;
}
};
const moveTouch = (event) => {
if (!isPanning.value || !touchStartX.value || !event.touches || event.touches.length !== 1) return;
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY;
// Сохраняем текущую позицию
touchEndX.value = currentX;
touchEndY.value = currentY;
const diffX = currentX - touchStartX.value;
const diffY = currentY - touchStartY.value;
// Определяем направление свайпа для индикаторов только при значительном движении
if (Math.abs(diffX) > 10 && Math.abs(diffX) > Math.abs(diffY)) {
if (diffX > 20) {
swipeDirection.value = 'right';
} else if (diffX < -20) {
swipeDirection.value = 'left';
} else {
swipeDirection.value = null;
}
// Применяем небольшой поворот для естественности
const rotate = diffX * 0.05;
// Применяем точное смещение к карточке, она следует за пальцем 1 к 1
cardStyle.value = {
transform: `translateX(${diffX}px) rotate(${rotate}deg)`,
transition: 'none'
};
// Блокируем скролл страницы при горизонтальном свайпе
event.preventDefault();
}
};
const endTouch = (event) => {
if (!isPanning.value) return;
isPanning.value = false;
// Проверяем, что у нас есть валидные координаты
if (!touchEndX.value || !touchStartX.value) {
// Если координаты отсутствуют, просто сбрасываем состояние
cardStyle.value = {
transform: 'none',
transition: 'transform 0.3s ease'
};
swipeDirection.value = null;
touchStartX.value = 0;
touchStartY.value = 0;
touchEndX.value = 0;
touchEndY.value = 0;
return;
}
const diffX = touchEndX.value - touchStartX.value;
// Проверяем, достаточно ли было смещение для действия
if (Math.abs(diffX) > swipeThreshold) {
// Если свайп был достаточно сильным, выполняем соответствующее действие
if (diffX > 0) {
// Свайп вправо (лайк)
cardStyle.value = {
transform: `translateX(120%) rotate(${swipeAngle}deg)`,
transition: 'transform 0.3s ease'
};
// Плавная анимация уходящей карточки
setTimeout(() => handleLike(), 300);
} else {
// Свайп влево (пропуск)
cardStyle.value = {
transform: `translateX(-120%) rotate(-${swipeAngle}deg)`,
transition: 'transform 0.3s ease'
};
// Плавная анимация уходящей карточки
setTimeout(() => handlePass(), 300);
}
} else {
// Если свайп недостаточный, возвращаем карточку в исходное положение с анимацией
cardStyle.value = {
transform: 'none',
transition: 'transform 0.3s ease'
};
swipeDirection.value = null;
}
// Сбрасываем значения
touchStartX.value = 0;
touchStartY.value = 0;
touchEndX.value = 0;
touchEndY.value = 0;
};
// Обработчики mouse событий
const startDrag = (event) => {
// Предотвращаем drag для изображений и других элементов
event.preventDefault();
isDragging.value = true;
dragStartX.value = event.clientX;
dragStartY.value = event.clientY;
dragStartTime.value = Date.now(); // Записываем время начала
hasActuallyMoved.value = false; // Сбрасываем флаг движения
// Сброс смещения
dragOffset.value = { x: 0, y: 0 };
// Добавляем слушатели событий мыши на document
document.addEventListener('mousemove', moveDrag);
document.addEventListener('mouseup', endDrag);
};
const moveDrag = (event) => {
if (!isDragging.value) return;
const currentX = event.clientX;
const currentY = event.clientY;
const newOffsetX = currentX - dragStartX.value;
const newOffsetY = currentY - dragStartY.value;
// Проверяем, было ли реальное движение (больше 3 пикселей)
if (Math.abs(newOffsetX) > 3 || Math.abs(newOffsetY) > 3) {
hasActuallyMoved.value = true;
}
dragOffset.value = {
x: newOffsetX,
y: newOffsetY
};
// Применяем стили только если было реальное движение
if (hasActuallyMoved.value) {
// Определяем направление свайпа для индикаторов
if (Math.abs(dragOffset.value.x) > Math.abs(dragOffset.value.y)) {
if (dragOffset.value.x > 20) {
swipeDirection.value = 'right';
} else if (dragOffset.value.x < -20) {
swipeDirection.value = 'left';
} else {
swipeDirection.value = null;
}
// Применяем небольшой поворот для естественности
const rotate = dragOffset.value.x * 0.05;
// Точное следование за курсором
cardStyle.value = {
transform: `translateX(${dragOffset.value.x}px) rotate(${rotate}deg)`,
transition: 'none'
};
}
}
};
const endDrag = () => {
if (!isDragging.value) return;
const dragDuration = Date.now() - dragStartTime.value;
const totalMovement = Math.sqrt(
Math.pow(dragOffset.value.x, 2) + Math.pow(dragOffset.value.y, 2)
);
// Проверяем условия для выполнения действия:
// 1. Было реальное движение больше порога
// 2. Горизонтальное смещение больше вертикального
// 3. Общее движение больше минимального
// 4. Время перетаскивания больше минимального (избегаем случайных кликов)
const shouldTriggerAction = hasActuallyMoved.value &&
Math.abs(dragOffset.value.x) > swipeThreshold &&
Math.abs(dragOffset.value.x) > Math.abs(dragOffset.value.y) &&
totalMovement > 10 &&
dragDuration > 100; // Минимум 100ms для считывания как drag
if (shouldTriggerAction) {
// Если перетаскивание было достаточно большим, выполняем действие лайка/пропуска
if (dragOffset.value.x > 0) {
cardStyle.value = {
transform: `translateX(120%) rotate(${swipeAngle}deg)`,
transition: 'transform 0.3s ease'
};
setTimeout(() => handleLike(), 300);
} else {
cardStyle.value = {
transform: `translateX(-120%) rotate(-${swipeAngle}deg)`,
transition: 'transform 0.3s ease'
};
setTimeout(() => handlePass(), 300);
}
} else {
// Если это был простой клик или недостаточное перетаскивание, возвращаем карточку в исходное положение
cardStyle.value = {
transform: 'none',
transition: 'transform 0.3s ease'
};
swipeDirection.value = null;
}
// Сбрасываем состояние и удаляем слушатели
isDragging.value = false;
hasActuallyMoved.value = false;
dragStartTime.value = 0;
document.removeEventListener('mousemove', moveDrag);
document.removeEventListener('mouseup', endDrag);
};
// Обработчик клика на карточку
const handleCardClick = (event, user) => {
console.log('[SwipeView] handleCardClick вызван для пользователя:', user.name, user._id);
// Проверяем, что клик не был на элементах управления каруселью
if (event.target.closest('.carousel-nav') ||
event.target.closest('.carousel-dots') ||
event.target.closest('.dot')) {
console.log('[SwipeView] Клик на элементах управления каруселью - не открываем профиль');
return;
}
// Проверяем, что в данный момент не происходит перетаскивание
if (isDragging.value || isPanning.value) {
console.log('[SwipeView] Клик проигнорирован - идет перетаскивание');
return;
}
// Проверяем, что было реальное движение только если dragStartTime был установлен
if (dragStartTime.value > 0) {
const dragDuration = Date.now() - dragStartTime.value;
const totalMovement = Math.sqrt(
Math.pow(dragOffset.value.x || 0, 2) + Math.pow(dragOffset.value.y || 0, 2)
);
console.log('[SwipeView] Проверка клика:', {
hasActuallyMoved: hasActuallyMoved.value,
totalMovement,
dragDuration,
dragOffset: dragOffset.value
});
// Если было реальное перетаскивание, не обрабатываем как клик
if (hasActuallyMoved.value || totalMovement > 10 || dragDuration < 300) {
console.log('[SwipeView] Клик проигнорирован - было перетаскивание');
return;
}
}
// Сбрасываем все состояния перетаскивания для чистого клика
dragStartTime.value = 0;
hasActuallyMoved.value = false;
dragOffset.value = { x: 0, y: 0 };
// Открываем полный профиль пользователя
console.log('[SwipeView] Переход к профилю пользователя:', user.name, user._id);
try {
router.push(`/user/${user._id}`);
console.log('[SwipeView] Маршрут установлен успешно');
} catch (error) {
console.error('[SwipeView] Ошибка при переходе к профилю:', error);
}
};
// Основные функции
const fetchSuggestions = async () => {
loading.value = true;
error.value = '';
matchOccurred.value = false;
try {
console.log('[SwipeView] Запрос предложений...');
const response = await api.getSuggestions();
suggestions.value = response.data;
currentIndex.value = 0;
console.log('[SwipeView] Предложения загружены:', suggestions.value);
// Инициализируем индексы фото для карусели
nextTick(() => {
initPhotoIndices();
});
} catch (err) {
console.error('[SwipeView] Ошибка при загрузке предложений:', err.response ? err.response.data : err.message);
error.value = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Не удалось загрузить анкеты. Попробуйте позже.';
if (err.response && err.response.status === 401) {
await logout();
}
} finally {
loading.value = false;
}
};
const handleAction = async (actionType) => {
if (!currentUserToSwipe.value || actionLoading.value) return;
actionLoading.value = true;
error.value = '';
if (actionType !== 'like') {
matchOccurred.value = false;
}
const targetUserId = currentUserToSwipe.value._id;
try {
let response;
if (actionType === 'like') {
console.log(`[SwipeView] Лайк пользователя: ${targetUserId}`);
response = await api.likeUser(targetUserId);
} else {
console.log(`[SwipeView] Пропуск пользователя: ${targetUserId}`);
response = await api.passUser(targetUserId);
}
console.log(`[SwipeView] Ответ на ${actionType}:`, response.data);
if (response.data.isMatch) {
matchOccurred.value = true;
matchedUserId.value = targetUserId;
} else {
moveToNextUser();
}
} catch (err) {
console.error(`[SwipeView] Ошибка при ${actionType}:`, err.response ? err.response.data : err.message);
error.value = `Ошибка при ${actionType === 'like' ? 'лайке' : 'пропуске'}. ${err.response?.data?.message || 'Попробуйте снова.'}`;
if (err.response && err.response.status === 401) {
await logout();
} else {
setTimeout(() => { error.value = ''; }, 3000);
}
} finally {
actionLoading.value = false;
}
};
const handleLike = () => {
// Анимация свайпа вправо
swipeDirection.value = 'right';
cardStyle.value = {
transform: `translateX(120%) rotate(${swipeAngle}deg)`,
transition: 'transform 0.5s ease'
};
// Задержка перед вызовом API
setTimeout(() => {
handleAction('like');
}, 300);
};
const handlePass = () => {
// Анимация свайпа влево
swipeDirection.value = 'left';
cardStyle.value = {
transform: `translateX(-120%) rotate(-${swipeAngle}deg)`,
transition: 'transform 0.5s ease'
};
// Задержка перед вызовом API
setTimeout(() => {
handleAction('pass');
}, 300);
};
const moveToNextUser = () => {
// Сбрасываем стили карточки
cardStyle.value = {
transform: 'none',
transition: 'none'
};
swipeDirection.value = null;
if (currentIndex.value < suggestions.value.length - 1) {
currentIndex.value++;
} else {
console.log('[SwipeView] Предложения закончились, перезагружаем...');
fetchSuggestions();
}
};
const continueSearching = () => {
matchOccurred.value = false;
moveToNextUser();
};
const goToChats = () => {
matchOccurred.value = false;
router.push('/chats');
};
const formatGender = (gender) => {
if (gender === 'male') return 'Мужчина';
if (gender === 'female') return 'Женщина';
if (gender === 'other') return 'Другой';
return '';
};
const onImageError = (event, userOnError, photoInfo, sourceType) => {
console.warn(
`SwipeView: Не удалось загрузить изображение (Источник: ${sourceType}).`,
{
src: event.target.src,
user: userOnError.name,
userId: userOnError._id,
photo_id: photoInfo._id,
photo_url: photoInfo.url,
isProfilePhotoFlag: photoInfo.isProfilePhoto,
event_type: event.type
}
);
};
// Добавляем функции для полного запрета скроллинга
const preventScroll = (e) => {
e.preventDefault();
e.stopPropagation();
return false;
};
const preventWheelScroll = (e) => {
// Запрещаем прокрутку колесом мыши
e.preventDefault();
e.stopPropagation();
return false;
};
const preventTouchScroll = (e) => {
// Разрешаем touch события только для карточек (свайп)
if (!e.target.closest('.swipe-card') && !e.target.closest('.carousel-nav') && !e.target.closest('.dot')) {
e.preventDefault();
e.stopPropagation();
return false;
}
};
// Обработчики событий жизненного цикла
onMounted(() => {
if (isAuthenticated.value) {
fetchSuggestions();
} else {
error.value = "Пожалуйста, войдите в систему, чтобы просматривать анкеты.";
loading.value = false;
}
// Добавляем обработчики для блокировки скроллинга
const swipeContentElement = document.querySelector('.swipe-content');
if (swipeContentElement) {
swipeContentElement.addEventListener('wheel', preventWheelScroll, { passive: false });
swipeContentElement.addEventListener('scroll', preventScroll, { passive: false });
swipeContentElement.addEventListener('touchmove', preventTouchScroll, { passive: false });
}
// Блокируем скроллинг на уровне всего документа при нахождении в SwipeView
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
});
onUnmounted(() => {
// Удаляем обработчики мыши при размонтировании компонента
document.removeEventListener('mousemove', moveDrag);
document.removeEventListener('mouseup', endDrag);
// Удаляем обработчики блокировки скроллинга
const swipeContentElement = document.querySelector('.swipe-content');
if (swipeContentElement) {
swipeContentElement.removeEventListener('wheel', preventWheelScroll);
swipeContentElement.removeEventListener('scroll', preventScroll);
swipeContentElement.removeEventListener('touchmove', preventTouchScroll);
}
// Восстанавливаем скроллинг при выходе из компонента
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
});
// Следим за изменениями в списке предложений
watch(suggestions, () => {
initPhotoIndices();
}, { deep: true });
// Следим за изменением текущего пользователя для записи просмотров
watch(currentUserToSwipe, async (newUser, oldUser) => {
if (newUser && newUser._id && (!oldUser || newUser._id !== oldUser._id)) {
// Записываем просмотр профиля для нового пользователя
try {
await api.recordProfileView(newUser._id, 'swipe');
console.log('[SwipeView] Просмотр профиля записан для пользователя:', newUser.name, newUser._id);
} catch (viewError) {
console.warn('[SwipeView] Не удалось записать просмотр профиля:', viewError);
// Не показываем ошибку пользователю, так как это не критично
}
}
});
</script>
<style scoped>
/* Основные стили контейнера */
.app-swipe-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden !important;
overscroll-behavior: none !important;
}
/* Блокировка прокрутки */
.no-scroll {
overflow: hidden !important;
touch-action: none !important;
position: relative;
overscroll-behavior: none !important;
-webkit-overflow-scrolling: auto !important;
}
/* Хедер */
.swipe-header {
background: rgba(33, 33, 60, 0.9);
backdrop-filter: blur(10px);
padding: 0.8rem 0;
text-align: center;
color: white;
position: sticky;
top: 0;
z-index: 10;
height: var(--header-height, 56px);
display: flex;
align-items: center;
justify-content: center;
}
.section-title {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Основная область контента */
.swipe-content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden !important;
overscroll-behavior: none !important;
padding-bottom: var(--tabs-height, 56px);
/* Полная блокировка всех видов скроллинга */
-webkit-overflow-scrolling: auto !important;
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
/* Скрываем полосы прокрутки во всех браузерах */
.swipe-content::-webkit-scrollbar {
display: none !important;
width: 0 !important;
}
/* Состояния загрузки и ошибки */
.loading-section,
.error-section,
.empty-section {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 1rem;
color: white;
overflow: hidden !important;
}
.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, .empty-card {
background: white;
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, .empty-card i {
font-size: 3rem;
margin-bottom: 1rem;
color: #495057;
}
.error-card h3, .empty-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;
display: inline-block;
}
/* Область свайпов */
.swipe-area {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
padding: 0.8rem;
touch-action: none !important;
overflow: hidden !important;
overscroll-behavior: none !important;
}
/* Стек карточек */
.card-stack {
flex: 1;
position: relative;
margin: 0 auto;
width: 100%;
max-width: 400px;
perspective: 1000px;
overflow: hidden !important;
overscroll-behavior: none !important;
}
/* Свайп-карточка */
.swipe-card {
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc(100% - 16px);
border-radius: 16px;
background-color: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
overflow: hidden !important;
display: flex;
flex-direction: column;
will-change: transform;
transition: transform 0.3s ease, opacity 0.3s ease, scale 0.3s ease;
cursor: pointer;
user-select: none;
touch-action: none !important;
overscroll-behavior: none !important;
}
.swipe-card.active {
z-index: 10;
}
.swipe-card.next {
z-index: 5;
transform: scale(0.95) translateY(15px);
opacity: 0.8;
}
/* Секция с фотографией */
.card-photo-section {
flex: 1;
position: relative;
min-height: 0;
max-height: 70vh;
overflow: hidden;
}
.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.7);
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
transition: all 0.2s ease;
}
.carousel-nav.prev {
left: 12px;
}
.carousel-nav.next {
right: 12px;
}
.carousel-dots {
position: absolute;
bottom: 15px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 6px;
z-index: 5;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
}
.dot.active {
background-color: white;
transform: scale(1.2);
}
/* Заполнители для фото */
.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;
}
/* Индикаторы действий */
.swipe-indicators {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.like-indicator, .pass-indicator {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0);
display: flex;
align-items: center;
justify-content: center;
width: 90px;
height: 90px;
border-radius: 50%;
font-size: 2.8rem;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.like-indicator {
right: 20%;
background-color: rgba(40, 167, 69, 0.8);
color: white;
}
.pass-indicator {
left: 20%;
background-color: rgba(220, 53, 69, 0.8);
color: white;
}
.like-indicator.visible, .pass-indicator.visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
/* Информация о пользователе */
.user-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
padding: 16px;
text-align: left;
}
.user-name {
margin: 0;
font-size: 1.6rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.user-gender {
margin: 5px 0 0;
opacity: 0.9;
}
/* Секция с деталями */
.card-details-section {
background: white;
padding: 12px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
flex-shrink: 0;
max-height: 100px;
overflow-y: auto;
}
/* Кнопки действий */
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
padding: 16px 0;
}
.action-btn {
width: 65px;
height: 65px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: white;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
.btn-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
}
.pass-btn .btn-icon {
color: #dc3545;
}
.like-btn .btn-icon {
color: #28a745;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Всплывающее окно совпадения */
.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;
}
/* Затемняющий оверлей */
.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-height: 700px) {
.card-photo-section {
max-height: 65vh;
}
}
@media (max-height: 600px) {
.card-photo-section {
max-height: 60vh;
}
.card-details-section {
padding: 8px 12px;
}
.bio-container {
max-height: 60px;
}
.action-buttons {
padding: 10px 0;
}
.action-btn {
width: 55px;
height: 55px;
}
.btn-icon {
font-size: 1.5rem;
}
}
/* Ландшафтная ориентация */
@media (max-height: 450px) and (orientation: landscape) {
.swipe-area {
flex-direction: row;
gap: 0.8rem;
padding: 0.5rem 0.8rem;
}
.card-stack {
max-width: calc(100% - 130px);
}
.action-buttons {
flex-direction: column;
height: 100%;
padding: 16px 0;
justify-content: center;
}
.swipe-header {
height: 46px;
padding: 0.5rem 0;
}
.section-title {
font-size: 1.1rem;
}
.card-details-section {
padding: 6px 10px;
}
.bio-container {
max-height: 50px;
}
.user-name {
font-size: 1.3rem;
}
}
/* Маленькие экраны */
@media (max-width: 375px) {
.user-name {
font-size: 1.4rem;
}
.action-btn {
width: 60px;
height: 60px;
}
.carousel-nav {
width: 32px;
height: 32px;
}
.like-indicator, .pass-indicator {
width: 80px;
height: 80px;
font-size: 2.5rem;
}
}
/* Учитываем Safe Area для iPhone X+ */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.action-buttons {
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
}
</style>