Reflex/src/views/SwipeView.vue

1528 lines
36 KiB
Vue
Raw Normal View History

2025-05-21 22:13:09 +07:00
<template>
<div class="modern-swipe-view">
<!-- Header Section -->
<div class="swipe-header">
<div class="container">
<h2 class="section-title">Поиск</h2>
</div>
</div>
2025-05-21 22:13:09 +07:00
<!-- Loading State -->
<div v-if="loading" class="loading-section">
<div class="loading-spinner">
<div class="spinner"></div>
<p>Загрузка анкет...</p>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 23:43:24 +07:00
</div>
2025-05-21 22:13:09 +07:00
</div>
</transition-group>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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';
2025-05-21 22:13:09 +07:00
const { isAuthenticated, logout } = useAuth();
2025-05-21 22:13:09 +07:00
// Основные данные
2025-05-21 22:13:09 +07:00
const suggestions = ref([]);
const currentIndex = ref(0);
const loading = ref(true);
2025-05-21 22:13:09 +07:00
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; // Угол наклона карты при свайпе (в градусах)
2025-05-21 22:13:09 +07:00
// Вычисляемые свойства
2025-05-21 22:13:09 +07:00
const currentUserToSwipe = computed(() => {
if (suggestions.value.length > 0 && currentIndex.value < suggestions.value.length) {
return suggestions.value[currentIndex.value];
2025-05-21 22:13:09 +07:00
}
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;
}
};
// Основные функции
2025-05-21 22:13:09 +07:00
const fetchSuggestions = async () => {
loading.value = true;
error.value = '';
matchOccurred.value = false;
2025-05-21 22:13:09 +07:00
try {
console.log('[SwipeView] Запрос предложений...');
const response = await api.getSuggestions();
suggestions.value = response.data;
currentIndex.value = 0;
2025-05-21 22:13:09 +07:00
console.log('[SwipeView] Предложения загружены:', suggestions.value);
// Инициализируем индексы фото для карусели
nextTick(() => {
initPhotoIndices();
});
2025-05-21 22:13:09 +07:00
} 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
: 'Не удалось загрузить анкеты. Попробуйте позже.';
2025-05-21 22:13:09 +07:00
if (err.response && err.response.status === 401) {
await logout();
2025-05-21 22:13:09 +07:00
}
} finally {
loading.value = false;
}
};
const handleAction = async (actionType) => {
if (!currentUserToSwipe.value || actionLoading.value) return;
2025-05-21 22:13:09 +07:00
actionLoading.value = true;
error.value = '';
2025-05-21 22:13:09 +07:00
if (actionType !== 'like') {
matchOccurred.value = false;
}
const targetUserId = currentUserToSwipe.value._id;
2025-05-21 22:13:09 +07:00
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);
2025-05-21 22:13:09 +07:00
if (response.data.isMatch) {
matchOccurred.value = true;
matchedUserId.value = targetUserId;
2025-05-21 22:13:09 +07:00
} else {
moveToNextUser();
}
} catch (err) {
console.error(`[SwipeView] Ошибка при ${actionType}:`, err.response ? err.response.data : err.message);
error.value = `Ошибка при ${actionType === 'like' ? 'лайке' : 'пропуске'}. ${err.response?.data?.message || 'Попробуйте снова.'}`;
2025-05-21 22:13:09 +07:00
if (err.response && err.response.status === 401) {
await logout();
2025-05-21 22:13:09 +07:00
} else {
setTimeout(() => { error.value = ''; }, 3000);
2025-05-21 22:13:09 +07:00
}
} 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);
2025-05-21 22:13:09 +07:00
};
const handlePass = () => {
// Анимация свайпа влево
swipeDirection.value = 'left';
cardStyle.value = {
transform: `translateX(-120%) rotate(-${swipeAngle}deg)`,
transition: 'transform 0.5s ease'
};
// Задержка перед вызовом API
setTimeout(() => {
handleAction('pass');
}, 300);
2025-05-21 22:13:09 +07:00
};
const moveToNextUser = () => {
// Сбрасываем стили карточки
cardStyle.value = {
transform: 'none',
transition: 'none'
};
swipeDirection.value = null;
2025-05-21 22:13:09 +07:00
if (currentIndex.value < suggestions.value.length - 1) {
currentIndex.value++;
} else {
console.log('[SwipeView] Предложения закончились, перезагружаем...');
fetchSuggestions();
2025-05-21 22:13:09 +07:00
}
};
const continueSearching = () => {
matchOccurred.value = false;
moveToNextUser();
};
const goToChats = () => {
matchOccurred.value = false;
router.push('/chats');
};
2025-05-21 22:13:09 +07:00
const formatGender = (gender) => {
if (gender === 'male') return 'Мужчина';
if (gender === 'female') return 'Женщина';
if (gender === 'other') return 'Другой';
return '';
2025-05-21 22:13:09 +07:00
};
const onImageError = (event, userOnError, photoInfo, sourceType) => {
2025-05-22 00:09:16 +07:00
console.warn(
`SwipeView: Не удалось загрузить изображение (Источник: ${sourceType}).`,
2025-05-22 00:09:16 +07:00
{
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
2025-05-21 23:43:24 +07:00
}
2025-05-22 00:09:16 +07:00
);
2025-05-21 22:13:09 +07:00
};
// Обработчики событий жизненного цикла
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 });
2025-05-21 22:13:09 +07:00
</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;
2025-05-21 22:13:09 +07:00
}
.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);
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
overflow: hidden;
}
/* Photo Carousel */
.photo-carousel {
width: 100%;
height: 100%;
position: relative;
2025-05-21 22:13:09 +07:00
}
.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);
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
}
.like-indicator {
right: 20%;
background-color: rgba(40, 167, 69, 0.8);
color: white;
2025-05-21 22:13:09 +07:00
}
.pass-indicator {
left: 20%;
background-color: rgba(220, 53, 69, 0.8);
2025-05-21 22:13:09 +07:00
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%);
2025-05-21 22:13:09 +07:00
color: white;
padding: 20px;
text-align: left;
2025-05-21 22:13:09 +07:00
}
.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);
2025-05-21 22:13:09 +07:00
}
.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);
2025-05-21 22:13:09 +07:00
}
.bio-container::-webkit-scrollbar {
width: 4px;
2025-05-21 22:13:09 +07:00
}
2025-05-22 00:04:45 +07:00
.bio-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
2025-05-21 23:43:24 +07:00
}
2025-05-22 00:04:45 +07:00
.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 {
2025-05-22 00:12:57 +07:00
width: 100%;
height: 100%;
2025-05-22 00:12:57 +07:00
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.8rem;
2025-05-22 00:12:57 +07:00
}
.pass-btn {
background-color: white;
color: #dc3545;
2025-05-22 00:12:57 +07:00
}
.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;
2025-05-22 00:04:45 +07:00
justify-content: center;
2025-05-21 22:13:09 +07:00
}
2025-05-22 00:04:45 +07:00
.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;
}
2025-05-21 22:13:09 +07:00
}
</style>