Files
dating-app-frontend/src/views/feed/FeedView.vue
Oscar 6119e8a2bd feat(src/router/index.ts): добавляет логику перенаправления для чатов в зависимости от активного диалога
 feat(src/views/chat/ChatRoomView.vue): обновляет модальное окно завершения диалога с новыми опциями

 feat(src/views/feed/FeedView.vue): добавляет уведомление о текущем активном матче в ленте

 feat(src/components/common/AppToast.vue): добавляет кнопку действия в уведомления для интерактивности

 feat(src/views/chat/ChatsListView.vue): сортирует чаты по статусу для улучшения пользовательского интерфейса
2026-06-09 15:38:22 +03:00

423 lines
11 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="feed-view">
<!-- Header bar -->
<header class="feed-header">
<h1 class="feed-header__title">
Лента
</h1>
<div class="feed-header__actions">
<!-- View mode toggle -->
<div class="feed-header__toggle" role="group" aria-label="Режим просмотра">
<button
class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'stack' }"
aria-label="Карточки"
@click="viewMode = 'stack'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="2" y="2" width="16" height="16" rx="2" />
<rect x="5" y="5" width="10" height="10" rx="1" stroke-dasharray="2 1" />
</svg>
</button>
<button
class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'scroll' }"
aria-label="Лента"
@click="viewMode = 'scroll'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M2 5h16M2 10h16M2 15h16" />
</svg>
</button>
</div>
<AppButton variant="secondary" size="sm" @click="filtersOpen = true">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<path d="M3 5h14M6 10h8M9 15h2" />
</svg>
Фильтры
</AppButton>
</div>
</header>
<!-- Active match overlay blocks the feed -->
<Transition name="fade">
<div v-if="activeMatchChatId" class="feed-locked" role="alertdialog" aria-modal="true">
<div class="feed-locked__inner">
<div class="feed-locked__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" width="40" height="40">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<h2 class="feed-locked__title">
У вас активный матч
</h2>
<p class="feed-locked__text">
Лента доступна только после завершения текущего диалога одно совпадение за раз.
</p>
<AppButton variant="primary" @click="goToChat">
Перейти в чат
</AppButton>
</div>
</div>
</Transition>
<!-- Card stack mode -->
<div v-if="viewMode === 'stack'" class="feed-view__stack">
<FeedCardStack />
</div>
<!-- Scroll mode grid of cards -->
<div v-else class="feed-view__scroll">
<EmptyState
v-if="!feedStore.loading && feedStore.cards.length === 0"
title="Анкеты закончились"
description="Новые люди появляются каждый день — загляните позже или смягчите фильтры"
icon="feed"
>
<AppButton variant="secondary" size="sm" @click="filtersOpen = true">
Изменить фильтры
</AppButton>
</EmptyState>
<div v-else class="feed-grid">
<article
v-for="profile in feedStore.cards"
:key="profile.id"
class="feed-grid__item"
:class="{ 'feed-grid__item--leaving': leavingCards.has(profile.id) }"
>
<RouterLink :to="`/profile/${profile.id}`" class="feed-grid__link">
<img
v-if="profile.media?.[0]?.path"
:src="profile.media[0].path"
:alt="profile.name"
class="feed-grid__img"
>
<div v-else class="feed-grid__no-img">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.25">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
</svg>
</div>
<div class="feed-grid__overlay">
<span class="feed-grid__name">{{ profile.name }}</span>
</div>
<button
class="feed-grid__like-btn"
:class="{ 'feed-grid__like-btn--active': leavingCards.has(profile.id) }"
aria-label="Лайк"
@click.prevent.stop="handleScrollLike(profile.id)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
</RouterLink>
</article>
</div>
<div v-if="feedStore.loading" class="feed-view__load-more">
<span class="meta">Загрузка...</span>
</div>
</div>
<FeedFilters :open="filtersOpen" @close="filtersOpen = false" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
import FeedFilters from '@/components/feed/FeedFilters.vue'
import { apiClient } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
const feedStore = useFeed()
const authStore = useAuth()
const uiStore = useUi()
const router = useRouter()
const activeProfileId = computed(() => authStore.activeProfile?.id)
const activeMatchChatId = computed(() => authStore.activeProfile?.activeChatId ?? null)
const { query: chatsQuery } = useChatsQuery(activeProfileId)
const filtersOpen = ref(false)
const viewMode = ref<'stack' | 'scroll'>('scroll')
const leavingCards = reactive(new Set<string>())
async function handleScrollLike(profileId: string) {
if (leavingCards.has(profileId))
return
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
leavingCards.add(profileId)
const [result] = await Promise.allSettled([
apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'like',
}),
new Promise(resolve => setTimeout(resolve, 380)),
])
leavingCards.delete(profileId)
feedStore.removeCard(profileId)
if (feedStore.cards.length < 5 && feedStore.hasMore)
feedStore.fetchNextPage()
if (result.status === 'rejected') {
uiStore.addToast('Не удалось отправить лайк', 'error')
}
}
function goToChat() {
const chatId = activeMatchChatId.value
if (chatId) {
router.push(`/chats/${chatId}`)
}
else {
const activeChat = chatsQuery.data.value?.find(c => c.status === 'active')
if (activeChat)
router.push(`/chats/${activeChat.id}`)
}
}
</script>
<style scoped lang="scss">
.feed-view {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
&__stack {
flex: 1;
padding: 16px;
position: relative;
}
&__scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
}
&__load-more {
text-align: center;
padding: 24px;
color: var(--color-muted);
}
}
.feed-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
}
&__toggle {
display: flex;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
&-btn {
width: 36px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-surface-3);
color: var(--color-cream);
}
&:hover:not(.feed-header__toggle-btn--active) {
color: var(--color-cream);
}
}
}
}
.feed-locked {
position: absolute;
inset: 0;
z-index: 10;
background: rgba(13, 13, 13, 0.92);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
&__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 320px;
text-align: center;
}
&__icon {
color: var(--color-signal);
opacity: 0.9;
}
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__text {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.6;
margin: 0;
}
}
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
&__item {
aspect-ratio: 3/4;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface-2);
will-change: transform, opacity;
transition:
transform 0.38s cubic-bezier(0.4, 0, 0.8, 0.2),
opacity 0.38s ease-in;
&--leaving {
transform: translateX(72px) rotate(8deg);
opacity: 0;
pointer-events: none;
}
}
&__link {
display: block;
position: relative;
width: 100%;
height: 100%;
text-decoration: none;
&:hover .feed-grid__overlay {
opacity: 1;
}
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__no-img {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-3);
}
&__overlay {
position: absolute;
inset: 0;
background: linear-gradient(0deg, rgba(13, 13, 13, 0.85) 0%, transparent 55%);
display: flex;
align-items: flex-end;
padding: 12px;
transition: opacity var(--transition-fast);
}
&__name {
font-family: var(--font-display);
font-size: 1rem;
color: var(--color-cream);
}
&__like-btn {
position: absolute;
bottom: 10px;
right: 10px;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--color-signal);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
opacity: 0;
transition:
transform var(--transition-fast),
opacity var(--transition-fast);
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&--active {
opacity: 1;
background: var(--color-signal);
svg {
fill: white;
stroke: white;
}
}
}
&__link:hover &__like-btn {
opacity: 1;
}
}
</style>