Reflex/src/views/SwipeView.vue

1528 lines
36 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-swipe-view">
<!-- Header Section -->
<div class="swipe-header">
<div class="container">
<h2 class="section-title">Поиск</h2>
</div>
</div>
<!-- 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="container">
<div class="error-card">
<i class="bi-exclamation-triangle"></i>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="retry-btn" @click="fetchSuggestions">Попробовать снова</button>
</div>
</div>
</div>
<!-- No Suggestions State -->
<div v-if="!loading && suggestions.length === 0 && !error" class="empty-section">
<div class="container">
<div class="empty-card">
<i class="bi-person-x"></i>
<h3>Нет анкет</h3>
<p>Пока нет доступных анкет. Попробуй зайти позже!</p>
</div>
</div>
</div>
<!-- Main Content with Swipeable Cards -->
<div v-if="!loading && suggestions.length > 0 && !error" class="swipe-content">
<div class="container">
<div class="swipe-area" ref="swipeArea">
<transition-group name="card-stack" tag="div" class="card-stack-container">
<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="startTouch"
@touchmove="moveTouch"
@touchend="endTouch"
@mousedown="startDrag"
@mousemove="moveDrag"
@mouseup="endDrag"
@mouseleave="cancelDrag"
>
<!-- Photo Section -->
<div class="card-photo-section">
<!-- Photo Carousel -->
<div class="photo-carousel">
<!-- Option 1: 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>
<!-- Option 2: 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>
<!-- Option 3: 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>
</transition-group>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
class="action-btn pass-btn"
:disabled="actionLoading"
@click="handlePass"
>
<div class="btn-icon">
<i class="bi-x-lg"></i>
</div>
</button>
<button
class="action-btn like-btn"
:disabled="actionLoading"
@click="handleLike"
>
<div class="btn-icon">
<i class="bi-heart-fill"></i>
</div>
</button>
</div>
</div>
</div>
<!-- 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 touchStartX = ref(0);
const touchStartY = ref(0);
const touchEndX = ref(0);
const touchEndY = ref(0);
const swipeThreshold = 100; // Минимальное расстояние для срабатывания свайпа
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragOffset = ref({ x: 0, y: 0 });
const cardStyle = ref({});
const swipeAngle = 12; // Угол наклона карты при свайпе (в градусах)
// Вычисляемые свойства
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 (isDragging.value) return; // Не меняем фото во время свайпа
currentPhotoIndex[userId] = index;
};
const nextPhoto = (userId) => {
if (isDragging.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 (isDragging.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;
}
};
// Методы для свайпа на мобильных
const startTouch = (event) => {
touchStartX.value = event.touches[0].clientX;
touchStartY.value = event.touches[0].clientY;
swipeDirection.value = null;
};
const moveTouch = (event) => {
if (!touchStartX.value) return;
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY;
// Сохраняем текущую позицию для использования в endTouch
touchEndX.value = currentX;
touchEndY.value = currentY;
const diffX = currentX - touchStartX.value;
const diffY = currentY - touchStartY.value;
// Применяем точное смещение к карточке, чтобы она следовала за пальцем
// Нет дополнительных коэффициентов, карточка перемещается точно на то же расстояние, что и палец
if (Math.abs(diffX) > Math.abs(diffY)) {
// Определяем направление свайпа для индикаторов
if (diffX > 20) {
swipeDirection.value = 'right';
} else if (diffX < -20) {
swipeDirection.value = 'left';
} else {
swipeDirection.value = null;
}
// Применяем небольшой поворот для естественности, но меньший чем был
// 0.05 вместо 0.1, чтобы эффект был легче
const rotate = diffX * 0.05;
cardStyle.value = {
transform: `translateX(${diffX}px) rotate(${rotate}deg)`,
transition: 'none'
};
// Блокируем скролл страницы
event.preventDefault();
}
};
const endTouch = () => {
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;
};
// Методы для свайпа на десктопе (drag)
const startDrag = (event) => {
isDragging.value = true;
dragStartX.value = event.clientX;
dragStartY.value = event.clientY;
dragOffset.value = { x: 0, y: 0 };
swipeDirection.value = null;
// Предотвращаем выделение текста
event.preventDefault();
};
const moveDrag = (event) => {
if (!isDragging.value) return;
dragOffset.value = {
x: event.clientX - dragStartX.value,
y: event.clientY - dragStartY.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'
};
}
event.preventDefault();
};
const endDrag = () => {
if (!isDragging.value) return;
if (Math.abs(dragOffset.value.x) > swipeThreshold) {
// Если перетаскивание было достаточно большим, выполняем действие лайка/пропуска с анимацией
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;
};
const cancelDrag = () => {
if (isDragging.value) {
cardStyle.value = {
transform: 'none',
transition: 'transform 0.3s ease'
};
swipeDirection.value = null;
isDragging.value = false;
}
};
// Основные функции
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
}
);
};
// Обработчики событий жизненного цикла
onMounted(() => {
if (isAuthenticated.value) {
fetchSuggestions();
} else {
error.value = "Пожалуйста, войдите в систему, чтобы просматривать анкеты.";
loading.value = false;
}
// Предотвращаем скролл страницы при свайпе
if (swipeArea.value) {
swipeArea.value.addEventListener('touchmove', (e) => {
if (isDragging.value) {
e.preventDefault();
}
}, { passive: false });
}
});
onUnmounted(() => {
// Удаляем слушатели событий
if (swipeArea.value) {
swipeArea.value.removeEventListener('touchmove', null);
}
});
// Следим за изменениями в списке предложений
watch(suggestions, () => {
initPhotoIndices();
}, { deep: true });
</script>
<style scoped>
/* Global Reset and Base Styles */
.modern-swipe-view {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed;
background-size: cover;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
width: 100%;
position: relative;
display: flex;
flex-direction: column;
}
.container {
max-width: 640px;
margin: 0 auto;
width: 100%;
padding: 0 15px;
}
/* Header Section */
.swipe-header {
background: rgba(33, 33, 60, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 1rem 0;
color: white;
position: sticky;
top: 0;
z-index: 10;
}
.section-title {
margin: 0;
font-size: 1.5rem;
text-align: center;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Loading State */
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
flex-grow: 1;
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 and Empty States */
.error-section, .empty-section {
padding: 4rem 0;
flex-grow: 1;
}
.error-card, .empty-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, .empty-card i {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-card i {
color: #dc3545;
}
.empty-card i {
color: #6c757d;
}
.error-card h3, .empty-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 */
.swipe-content {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 1rem 0;
}
/* Swipe Area */
.swipe-area {
position: relative;
height: 68vh;
max-height: 680px;
min-height: 450px;
margin: 0 auto;
width: 100%;
perspective: 1000px;
touch-action: none;
user-select: none;
}
/* Card Stack */
.card-stack-container {
position: relative;
width: 100%;
height: 100%;
perspective: 1000px;
}
/* Swipe Card */
.swipe-card {
position: absolute;
width: 100%;
height: 100%;
border-radius: 20px;
background-color: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
overflow: hidden;
transition: transform 0.3s ease, opacity 0.3s ease, scale 0.3s ease;
cursor: grab;
will-change: transform;
display: flex;
flex-direction: column;
}
.swipe-card.active {
z-index: 10;
}
.swipe-card.next {
z-index: 5;
transform: scale(0.95) translateY(15px);
opacity: 0.8;
}
/* Card Animations */
.swipe-card.swiping-left {
animation: swipeLeft 0.5s forwards;
}
.swipe-card.swiping-right {
animation: swipeRight 0.5s forwards;
}
@keyframes swipeLeft {
to {
transform: translateX(-120%) rotate(-12deg);
opacity: 0;
}
}
@keyframes swipeRight {
to {
transform: translateX(120%) rotate(12deg);
opacity: 0;
}
}
/* Card-Stack Transitions */
.card-stack-enter-active, .card-stack-leave-active {
transition: transform 0.5s ease, opacity 0.5s ease;
}
.card-stack-enter-from, .card-stack-leave-to {
opacity: 0;
transform: translateX(100%) rotate(5deg);
}
/* Photo Section */
.card-photo-section {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
}
/* Photo Carousel */
.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 Navigation */
.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.3s ease;
}
.carousel-nav:hover {
background: rgba(255, 255, 255, 0.9);
transform: translateY(-50%) scale(1.1);
}
.carousel-nav.prev {
left: 10px;
}
.carousel-nav.next {
right: 10px;
}
/* Carousel Dots */
.carousel-dots {
position: absolute;
bottom: 15px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
z-index: 5;
}
.dot {
width: 8px;
height: 8px;
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.3);
}
/* Single Photo */
.single-photo {
width: 100%;
height: 100%;
}
/* No Photo Placeholder */
.no-photo {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e9ecef;
}
.no-photo i {
font-size: 5rem;
color: #adb5bd;
}
/* Swipe Indicators */
.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: 100px;
height: 100px;
border-radius: 50%;
font-size: 3rem;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s 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 */
.user-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
color: white;
padding: 20px;
text-align: left;
}
.user-name {
font-size: 1.8rem;
margin: 0;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.user-age {
font-weight: normal;
}
.user-gender {
margin: 5px 0 0;
opacity: 0.9;
font-size: 0.95rem;
}
/* Card Details */
.card-details-section {
padding: 15px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.bio-container {
max-height: 80px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(102, 126, 234, 0.5) rgba(255, 255, 255, 0.1);
}
.bio-container::-webkit-scrollbar {
width: 4px;
}
.bio-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.bio-container::-webkit-scrollbar-thumb {
background-color: rgba(102, 126, 234, 0.5);
border-radius: 4px;
}
.bio-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: #333;
}
/* Action Buttons */
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
padding-bottom: 20px;
}
.action-btn {
width: 70px;
height: 70px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
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;
border-radius: 50%;
font-size: 1.8rem;
}
.pass-btn {
background-color: white;
color: #dc3545;
}
.pass-btn:hover:not(:disabled) {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(220, 53, 69, 0.3);
}
.like-btn {
background-color: white;
color: #28a745;
}
.like-btn:hover:not(:disabled) {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(40, 167, 69, 0.3);
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Match Popup */
.match-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 20px;
padding: 2rem;
width: 90%;
max-width: 400px;
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;
display: block;
margin-bottom: 1rem;
}
.match-header h3 {
font-size: 1.5rem;
color: #333;
margin: 0;
}
.match-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.match-btn {
flex: 1;
padding: 0.8rem 1rem;
border-radius: 50px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.match-btn.primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
}
.match-btn.secondary {
background: #f1f3f5;
color: #495057;
}
.match-btn:hover {
transform: translateY(-2px);
}
.match-btn.primary:hover {
box-shadow: 0 6px 15px rgba(102, 126, 234, 0.4);
}
/* Overlay */
.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 Animation */
.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;
}
/* Responsive Adjustments */
@media (max-width: 576px) {
.swipe-area {
height: 60vh;
min-height: 400px;
}
.action-btn {
width: 60px;
height: 60px;
}
.user-name {
font-size: 1.5rem;
}
.card-details-section {
padding: 10px;
}
.bio-container {
max-height: 60px;
}
.section-title {
font-size: 1.3rem;
}
.like-indicator, .pass-indicator {
width: 80px;
height: 80px;
font-size: 2.5rem;
}
.carousel-nav {
width: 32px;
height: 32px;
}
/* Match Popup адаптация для мобильных */
.match-popup {
width: 95%;
max-width: 340px;
padding: 1.5rem 1rem;
}
.match-header i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.match-header h3 {
font-size: 1.3rem;
}
.match-actions {
flex-direction: column;
}
.match-btn {
padding: 0.7rem 0.8rem;
font-size: 0.95rem;
}
}
/* Small phones */
@media (max-width: 375px) {
.swipe-area {
height: 55vh;
min-height: 350px;
}
.action-btn {
width: 50px;
height: 50px;
}
.user-name {
font-size: 1.3rem;
}
.user-gender {
font-size: 0.85rem;
}
.bio-text {
font-size: 0.9rem;
}
.action-buttons {
gap: 15px;
}
.like-indicator, .pass-indicator {
width: 70px;
height: 70px;
font-size: 2rem;
}
/* Match Popup адаптация для малых экранов */
.match-popup {
width: 100%;
max-width: 300px;
padding: 1.25rem 0.75rem;
border-radius: 15px;
}
.match-header i {
font-size: 2.2rem;
margin-bottom: 0.5rem;
}
.match-header h3 {
font-size: 1.2rem;
}
.match-btn {
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
}
/* Large phones and small tablets */
@media (min-width: 577px) and (max-width: 767px) {
.swipe-area {
height: 65vh;
max-height: 600px;
}
.action-buttons {
margin-top: 15px;
}
/* Match Popup адаптация для средних экранов */
.match-popup {
width: 85%;
max-width: 380px;
padding: 1.75rem;
}
.match-header i {
font-size: 2.8rem;
}
}
/* Tablets */
@media (min-width: 768px) and (max-width: 991px) {
.container {
max-width: 700px;
}
.swipe-area {
height: 70vh;
max-height: 700px;
}
.action-btn {
width: 75px;
height: 75px;
}
.btn-icon {
font-size: 2rem;
}
/* Match Popup адаптация для планшетов */
.match-popup {
width: 80%;
max-width: 450px;
}
.match-header i {
font-size: 3.2rem;
}
.match-btn {
padding: 0.9rem 1.2rem;
font-size: 1rem;
}
}
/* Small desktops */
@media (min-width: 992px) and (max-width: 1199px) {
.container {
max-width: 800px;
}
.swipe-area {
max-height: 750px;
}
/* Match Popup адаптация для настольных */
.match-popup {
max-width: 480px;
padding: 2.25rem;
}
}
/* Large desktops */
@media (min-width: 1200px) {
.container {
max-width: 850px;
}
.swipe-area {
max-height: 800px;
}
}
/* Landscape orientation on mobile */
@media (max-height: 576px) and (orientation: landscape) {
.swipe-area {
height: 80vh;
min-height: 280px;
}
.action-buttons {
margin-top: 10px;
padding-bottom: 10px;
}
.action-btn {
width: 50px;
height: 50px;
}
.bio-container {
max-height: 40px;
}
.card-details-section {
padding: 8px;
}
.user-info-overlay {
padding: 10px;
}
.user-name {
font-size: 1.3rem;
}
/* Match Popup адаптация для ландшафтной ориентации */
.match-popup {
width: 70%;
max-width: 450px;
padding: 1rem 1.5rem;
display: flex;
flex-direction: row;
align-items: center;
text-align: left;
}
.match-content {
display: flex;
width: 100%;
align-items: center;
}
.match-header {
margin-bottom: 0;
margin-right: 1.5rem;
flex: 1;
}
.match-actions {
flex-direction: column;
flex: 1;
gap: 0.7rem;
}
.match-btn {
padding: 0.6rem 0.8rem;
}
}
</style>