feat(src/composables/useFeed.ts): добавляет поддержку бесконечной прокрутки для ленты новостей

 feat(package.json): добавляет скрипт для генерации спрайта и обновляет зависимости
🔧 refactor(src/composables/useChat.ts): упрощает логику управления чатом, убирает устаревшие функции
🔧 refactor(src/views/chat/ChatRoomView.vue): обновляет логику загрузки сообщений с использованием новых хуков
🔧 refactor(src/views/matches/MatchesView.vue): улучшает обработку совпадений с использованием новых хуков
 feat(src/App.vue): добавляет загрузку справочных данных с использованием Vue Query
 feat(src/main.ts): интегрирует Vue Query в приложение для управления состоянием запросов
 feat(src/views/profile/MyProfileView.vue): добавляет мутацию для удаления профиля с использованием Vue Query
🔧 refactor(src/components/layout/BottomNav.vue): заменяет компонент иконки на более универсальный компонент AppIcon
This commit is contained in:
Oscar
2026-06-09 10:29:52 +03:00
parent 56c7fb22e9
commit 78fe3b5a98
46 changed files with 658 additions and 416 deletions

View File

@@ -5,13 +5,15 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build": "node scripts/generate-sprite.js && vue-tsc && vite build",
"icons": "node scripts/generate-sprite.js",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint . --fix",
"gen:api": "npx swagger-typescript-api@12.0.4 -p http://localhost:1337/api/docs-json -o ./src/api -n api.ts --axios --unwrap-response-data --extract-request-params --extract-request-body --single-http-client"
},
"dependencies": {
"@tanstack/vue-query": "^5.101.0",
"@floating-ui/vue": "^1.1.5",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",

38
pnpm-lock.yaml generated
View File

@@ -13,6 +13,9 @@ importers:
'@floating-ui/vue':
specifier: ^1.1.5
version: 1.1.11(vue@3.5.35(typescript@5.9.3))
'@tanstack/vue-query':
specifier: ^5.101.0
version: 5.101.0(vue@3.5.35(typescript@5.9.3))
'@tauri-apps/api':
specifier: ^2
version: 2.11.0
@@ -1086,6 +1089,22 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/query-core@5.101.0':
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
'@tanstack/vue-query@5.101.0':
resolution: {integrity: sha512-sZeW0RvfEZ9QRiaXirE/HQZsFT5saMlPZVfeYvjPX6lqSBS9lkD7wfnCfzOBns6HD2f34Gx9cazkuU3Xj6hl/A==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
@@ -2457,6 +2476,9 @@ packages:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
hasBin: true
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
engines: {node: '>=18'}
@@ -3373,6 +3395,20 @@ snapshots:
tailwindcss: 4.3.0
vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(yaml@2.9.0)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.101.0': {}
'@tanstack/vue-query@5.101.0(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/query-core': 5.101.0
'@vue/devtools-api': 6.6.4
vue: 3.5.35(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.11.2':
@@ -4998,6 +5034,8 @@ snapshots:
dependencies:
jsesc: 3.1.0
remove-accents@0.5.0: {}
reserved-identifiers@1.2.0: {}
resolve-pkg-maps@1.0.0: {}

View File

@@ -0,0 +1,49 @@
import { readFileSync, writeFileSync, readdirSync } from 'fs'
import { join, basename } from 'path'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const iconsDir = join(__dirname, '../src/assets/icons')
const svgFiles = readdirSync(iconsDir)
.filter(f => f.endsWith('.svg'))
.sort()
if (!svgFiles.length) {
console.error('No SVG files found in', iconsDir)
process.exit(1)
}
// Extract viewBox and inner content from each SVG file
const symbols = svgFiles.map((file) => {
const name = basename(file, '.svg')
const raw = readFileSync(join(iconsDir, file), 'utf-8')
const viewBox = (raw.match(/viewBox="([^"]+)"/) ?? [])[1] ?? '0 0 24 24'
// Everything between <svg ...> and </svg>
const inner = (raw.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i) ?? [])[1]
?.trim()
.replace(/\n\s*/g, '\n ') ?? ''
return ` <symbol id="icon-${name}" viewBox="${viewBox}">\n ${inner}\n </symbol>`
})
// --- sprite.svg ---
const sprite = `<!-- Generated by scripts/generate-sprite.js — do not edit manually -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
${symbols.join('\n')}
</svg>
`
writeFileSync(join(iconsDir, 'sprite.svg'), sprite)
// --- icon-names.ts (IconName union type) ---
const names = svgFiles.map(f => `'${basename(f, '.svg')}'`).join('\n | ')
const meta = `// Generated by scripts/generate-sprite.js — do not edit manually
export type IconName =
| ${names}
`
writeFileSync(join(iconsDir, 'icon-names.ts'), meta)
console.log(`✓ sprite.svg — ${svgFiles.length} icons`)
console.log('✓ icon-names.ts')

View File

@@ -15,34 +15,37 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { watch } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import AppToast from '@/components/common/AppToast.vue'
import AppShell from '@/components/layout/AppShell.vue'
import { useUi } from '@/composables/useUi'
import { queryKeys } from '@/queries/keys'
const uiStore = useUi()
async function loadReferences() {
if (uiStore.referencesLoaded)
return
try {
const [tags, cities, greetings] = await Promise.all([
apiClient.api.tagsControllerFindAll(),
apiClient.api.citiesControllerFindAll(),
apiClient.api.greetingsControllerFindAll(),
])
uiStore.setTags(tags)
uiStore.setCities(cities)
uiStore.setGreetings(greetings)
uiStore.setReferencesLoaded()
}
catch {
// Reference data loading is best-effort
}
}
const { data: tagsData } = useQuery({
queryKey: queryKeys.tags(),
queryFn: () => apiClient.api.tagsControllerFindAll(),
staleTime: Infinity,
})
onMounted(loadReferences)
const { data: citiesData } = useQuery({
queryKey: queryKeys.cities(),
queryFn: () => apiClient.api.citiesControllerFindAll(),
staleTime: Infinity,
})
const { data: greetingsData } = useQuery({
queryKey: queryKeys.greetings(),
queryFn: () => apiClient.api.greetingsControllerFindAll(),
staleTime: Infinity,
})
watch(tagsData, (v) => { if (v) { uiStore.setTags(v); uiStore.setReferencesLoaded() } })
watch(citiesData, v => v && uiStore.setCities(v))
watch(greetingsData, v => v && uiStore.setGreetings(v))
</script>
<style>

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
</svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<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>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,19 @@
// Generated by scripts/generate-sprite.js — do not edit manually
export type IconName =
| 'calendar'
| 'chat'
| 'check'
| 'chevron-left'
| 'chevron-right'
| 'edit'
| 'flag'
| 'grid'
| 'heart'
| 'image'
| 'mic'
| 'person'
| 'plus'
| 'search'
| 'send'
| 'trash'
| 'x'

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

5
src/assets/icons/mic.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="1" width="6" height="11" rx="3"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<path d="M12 19v4M8 23h8"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"/>
<path d="M21 21l-4.35-4.35"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"/>
<path d="M22 2L15 22l-4-9-9-4 20-7z"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

@@ -0,0 +1,64 @@
<!-- Generated by scripts/generate-sprite.js — do not edit manually -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-calendar" viewBox="0 0 24 24">
<path d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</symbol>
<symbol id="icon-chat" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M20 6L9 17l-5-5"/>
</symbol>
<symbol id="icon-chevron-left" viewBox="0 0 24 24">
<path d="M15 18l-6-6 6-6"/>
</symbol>
<symbol id="icon-chevron-right" viewBox="0 0 24 24">
<path d="M9 18l6-6-6-6"/>
</symbol>
<symbol id="icon-edit" viewBox="0 0 24 24">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</symbol>
<symbol id="icon-flag" viewBox="0 0 24 24">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>
</symbol>
<symbol id="icon-grid" viewBox="0 0 24 24">
<path d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
</symbol>
<symbol id="icon-heart" viewBox="0 0 24 24">
<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"/>
</symbol>
<symbol id="icon-image" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</symbol>
<symbol id="icon-mic" viewBox="0 0 24 24">
<rect x="9" y="1" width="6" height="11" rx="3"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<path d="M12 19v4M8 23h8"/>
</symbol>
<symbol id="icon-person" viewBox="0 0 24 24">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</symbol>
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14"/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7"/>
<path d="M21 21l-4.35-4.35"/>
</symbol>
<symbol id="icon-send" viewBox="0 0 24 24">
<path d="M22 2L11 13"/>
<path d="M22 2L15 22l-4-9-9-4 20-7z"/>
</symbol>
<symbol id="icon-trash" viewBox="0 0 24 24">
<path d="M3 6h18"/>
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</symbol>
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

3
src/assets/icons/x.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1,37 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
:aria-label="ariaLabel"
:aria-hidden="!ariaLabel || undefined"
:role="ariaLabel ? 'img' : undefined"
class="app-icon"
>
<use :href="`#icon-${name}`" />
</svg>
</template>
<script setup lang="ts">
import type { IconName } from '@/assets/icons/icon-names'
withDefaults(defineProps<{
name: IconName
size?: number | string
ariaLabel?: string
}>(), {
size: 24,
})
</script>
<style scoped>
.app-icon {
display: block;
flex-shrink: 0;
}
</style>

View File

@@ -56,14 +56,14 @@ async function handleLike(profileId: string) {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
feedStore.removeCard(profileId)
checkRefill()
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'like',
})
feedStore.removeCard(profileId)
checkRefill()
}
catch {
uiStore.addToast('Не удалось отправить лайк', 'error')
@@ -74,26 +74,23 @@ async function handleDislike(profileId: string) {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
feedStore.removeCard(profileId)
checkRefill()
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'dislike',
})
feedStore.removeCard(profileId)
checkRefill()
}
catch {
feedStore.removeCard(profileId)
// Dislike failures are silent — card is already removed
}
}
function checkRefill() {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
if (feedStore.cards.length < 5 && feedStore.hasMore) {
feedStore.fetchNextPage(activeProfile.id)
feedStore.fetchNextPage()
}
}
</script>

View File

@@ -122,9 +122,7 @@ function toggleTag(tagId: string) {
function apply() {
feedStore.applyFilters({ ...filters })
const profileId = authStore.activeProfile?.id
if (profileId)
feedStore.fetchNextPage(profileId)
feedStore.fetchNextPage()
emit('close')
}

View File

@@ -10,7 +10,7 @@
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<span class="bottom-nav__icon">
<BottomNavIcon :name="item.icon" />
<AppIcon :name="item.icon" :size="22" />
</span>
<span class="bottom-nav__label">{{ item.label }}</span>
</RouterLink>
@@ -19,10 +19,12 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import AppIcon from '@/components/common/AppIcon.vue'
import type { IconName } from '@/assets/icons/icon-names'
const route = useRoute()
const navItems = [
const navItems: { path: string, label: string, icon: IconName }[] = [
{ path: '/feed', label: 'Лента', icon: 'grid' },
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
{ path: '/chats', label: 'Чаты', icon: 'chat' },
@@ -35,21 +37,6 @@ function isActive(path: string) {
}
</script>
<script lang="ts">
const BottomNavIcon = {
props: { name: String },
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path v-if="name==='grid'" d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
<path v-if="name==='heart'" 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"/>
<path v-if="name==='chat'" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path v-if="name==='calendar'" d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<path v-if="name==='person'" 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>
`,
}
</script>
<style scoped lang="scss">
.bottom-nav {
display: flex;
@@ -86,11 +73,6 @@ const BottomNavIcon = {
&__icon {
width: 22px;
height: 22px;
svg {
width: 100%;
height: 100%;
}
}
&__label {

View File

@@ -20,8 +20,8 @@
:aria-label="item.label"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<span class="sidenav__icon" :aria-hidden="true">
<NavIcon :name="item.icon" />
<span class="sidenav__icon" aria-hidden="true">
<AppIcon :name="item.icon" :size="20" />
</span>
<Transition name="fade">
<span v-if="uiStore.sidebarExpanded" class="sidenav__label">{{ item.label }}</span>
@@ -37,7 +37,7 @@
@click="toggle"
>
<span class="sidenav__icon">
<NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" />
<AppIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" :size="20" />
</span>
</button>
</div>
@@ -49,6 +49,8 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import AppIcon from '@/components/common/AppIcon.vue'
import type { IconName } from '@/assets/icons/icon-names'
const route = useRoute()
const authStore = useAuth()
@@ -58,7 +60,7 @@ interface NavItem {
name: string
path: string
label: string
icon: string
icon: IconName
adminOnly?: boolean
}
@@ -87,25 +89,6 @@ function toggle() {
}
</script>
<script lang="ts">
// Inline icon renderer to avoid external icon library dependency
const NavIcon = {
props: { name: String },
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-if="name==='grid'" d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
<path v-if="name==='heart'" 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"/>
<path v-if="name==='chat'" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path v-if="name==='calendar'" d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<path v-if="name==='person'" 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"/>
<path v-if="name==='flag'" d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>
<path v-if="name==='chevron-right'" d="M9 18l6-6-6-6"/>
<path v-if="name==='chevron-left'" d="M15 18l-6-6 6-6"/>
</svg>
`,
}
</script>
<style scoped lang="scss">
.sidenav {
width: var(--sidebar-collapsed);
@@ -189,11 +172,6 @@ const NavIcon = {
width: 20px;
height: 20px;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
&__label {

View File

@@ -1,109 +1,9 @@
import type { ChatDto, MessageDto, SendMessageDto } from '@/api/api'
import { reactive, ref } from 'vue'
import { apiClient } from '@/api/client'
export interface ChatProfile {
id: string
name: string
avatarUrl?: string
}
export type Chat = ChatDto & {
partner?: ChatProfile
lastMessage?: MessageDto
unreadCount?: number
createdAt?: string
}
import type { MessageDto } from '@/api/api'
// ChatMessage type kept for backward compatibility with components that import it
export type ChatMessage = MessageDto
// Polling interval — replace with WebSocket when backend supports it
const POLL_INTERVAL = 2000
const chats = ref<Chat[]>([])
const activeChat = ref<Chat | null>(null)
const messages = ref<ChatMessage[]>([])
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
const loading = ref(false)
async function fetchChats(profileId: string) {
const res = await apiClient.api.chatControllerGetChats({ profileId })
chats.value = res
}
async function fetchMessages(chatId: string, profileId: string) {
loading.value = true
try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId })
messages.value = res
}
finally {
loading.value = false
}
}
async function sendMessage(chatId: string, profileId: string, dto: SendMessageDto) {
const res = await apiClient.api.chatControllerSendMessage({ chatId, profileId }, dto)
messages.value.push(res)
return res
}
async function openChat(profileId: string, matchId: string) {
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId })
const existing = chats.value.findIndex(c => c.id === res.id)
if (existing === -1)
chats.value.unshift(res)
activeChat.value = res
return res
}
async function closeChat(chatId: string, profileId: string) {
await apiClient.api.chatControllerCloseChat({ chatId, profileId })
chats.value = chats.value.filter(c => c.id !== chatId)
if (activeChat.value?.id === chatId)
activeChat.value = null
}
function startPolling(chatId: string, profileId: string) {
stopPolling()
// TODO: replace with WebSocket subscription
pollingTimer.value = setInterval(async () => {
try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId })
if (res.length > messages.value.length) {
messages.value = res
}
}
catch {
// polling errors are silent
}
}, POLL_INTERVAL)
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
function setActiveChat(chat: Chat | null) {
activeChat.value = chat
}
// Chat data fetching is handled by useChatsQuery and useMessagesQuery
export function useChat() {
return reactive({
chats,
activeChat,
messages,
loading,
fetchChats,
fetchMessages,
sendMessage,
openChat,
closeChat,
startPolling,
stopPolling,
setActiveChat,
})
return {}
}

View File

@@ -1,57 +1,72 @@
import type { FeedControllerGetFeedParams, ProfileResponseDto } from '@/api/api'
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from '@/queries/keys'
import { useAuth } from './useAuth'
export type FeedProfile = ProfileResponseDto & { age?: number | null, cityName?: string }
const cards = ref<FeedProfile[]>([])
// Module-level state: filters and searchPaused persist across navigations
const filters = reactive<Partial<FeedControllerGetFeedParams>>({})
const page = ref(1)
const hasMore = ref(true)
const searchPaused = ref(false)
const loading = ref(false)
async function fetchNextPage(profileId: string) {
if (loading.value || !hasMore.value)
return
loading.value = true
try {
const res = await apiClient.api.feedControllerGetFeed({
profileId,
page: page.value,
limit: 20,
...filters,
})
console.log(res.items)
if (page.value === 1)
cards.value = res.items
else cards.value.push(...res.items)
hasMore.value = res.items.length >= res.limit
page.value++
}
finally {
loading.value = false
}
}
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
Object.assign(filters, newFilters)
reset()
}
function reset() {
cards.value = []
page.value = 1
hasMore.value = true
}
function removeCard(profileId: string) {
cards.value = cards.value.filter(c => c.id !== profileId)
}
export function useFeed() {
return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard })
const authStore = useAuth()
const qc = useQueryClient()
const profileId = computed(() => authStore.activeProfile?.id)
const query = useInfiniteQuery({
queryKey: computed(() => queryKeys.feed(profileId.value ?? '', { ...filters })),
queryFn: ({ pageParam }) =>
apiClient.api.feedControllerGetFeed({
profileId: profileId.value!,
page: pageParam as number,
limit: 20,
...filters,
}),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.items.length >= lastPage.limit ? allPages.length + 1 : undefined,
enabled: computed(() => !!profileId.value),
})
const cards = computed((): FeedProfile[] =>
query.data.value?.pages.flatMap(p => p.items) ?? [],
)
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
Object.assign(filters, newFilters)
}
function removeCard(cardProfileId: string) {
const key = queryKeys.feed(profileId.value ?? '', { ...filters })
qc.setQueryData(key, (old: typeof query.data.value) => {
if (!old)
return old
return {
...old,
pages: old.pages.map(p => ({
...p,
items: p.items.filter((c: FeedProfile) => c.id !== cardProfileId),
})),
}
})
}
function reset() {
qc.resetQueries({ queryKey: ['feed', profileId.value ?? ''] })
}
return reactive({
cards,
loading: query.isFetching,
hasMore: query.hasNextPage,
searchPaused,
filters,
fetchNextPage: () => query.fetchNextPage(),
applyFilters,
removeCard,
reset,
})
}

View File

@@ -1,10 +1,30 @@
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
import { router } from './router'
import '@/styles/tailwind.css'
import '@/styles/main.scss'
import sprite from '@/assets/icons/sprite.svg?raw'
// Inject SVG sprite into DOM once so <use href="#icon-*"> works everywhere
const spriteContainer = document.createElement('div')
spriteContainer.style.cssText = 'display:none;position:absolute;width:0;height:0'
spriteContainer.setAttribute('aria-hidden', 'true')
spriteContainer.innerHTML = sprite
document.body.prepend(spriteContainer)
const app = createApp(App)
app.use(router)
app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: 1,
},
},
},
})
app.mount('#app')

17
src/queries/keys.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { FeedControllerGetFeedParams } from '@/api/api'
export const queryKeys = {
me: () => ['me'] as const,
profile: (id: string) => ['profile', id] as const,
feed: (profileId: string, filters: Partial<FeedControllerGetFeedParams> = {}) =>
['feed', profileId, filters] as const,
matches: (profileId: string) => ['matches', profileId] as const,
chats: (profileId: string) => ['chats', profileId] as const,
messages: (chatId: string, profileId: string) => ['messages', chatId, profileId] as const,
dates: (profileId: string) => ['dates', profileId] as const,
dateStatuses: () => ['dateStatuses'] as const,
reports: () => ['reports'] as const,
tags: () => ['tags'] as const,
cities: () => ['cities'] as const,
greetings: () => ['greetings'] as const,
}

View File

@@ -0,0 +1,44 @@
import type { ChatDto, MessageDto } from '@/api/api'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
export interface ChatPartner {
id: string
name: string
avatarUrl?: string
}
// The API returns richer chat objects than ChatDto — these extra fields aren't in the generated types
export type Chat = ChatDto & {
partner?: ChatPartner
lastMessage?: MessageDto
unreadCount?: number
}
export function useChatsQuery(profileId: MaybeRefOrGetter<string | undefined>) {
const qc = useQueryClient()
const pid = () => toValue(profileId) ?? ''
const query = useQuery({
queryKey: computed(() => queryKeys.chats(pid())),
queryFn: () => apiClient.api.chatControllerGetChats({ profileId: pid() }) as unknown as Promise<Chat[]>,
enabled: computed(() => !!toValue(profileId)),
})
const openChat = useMutation({
mutationFn: ({ matchId }: { matchId: string }) =>
apiClient.api.chatControllerCreateChat({ profileId: pid(), matchId }),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.chats(pid()) }),
})
const closeChat = useMutation({
mutationFn: ({ chatId }: { chatId: string }) =>
apiClient.api.chatControllerCloseChat({ chatId, profileId: pid() }),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.chats(pid()) }),
})
return { query, openChat, closeChat }
}

View File

@@ -0,0 +1,34 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
export function useDatesQuery(profileId: MaybeRefOrGetter<string | undefined>) {
const qc = useQueryClient()
const pid = () => toValue(profileId) ?? ''
const datesQuery = useQuery({
queryKey: computed(() => queryKeys.dates(pid())),
queryFn: () => apiClient.api.datesControllerGetDates({ profileId: pid() }),
enabled: computed(() => !!toValue(profileId)),
})
const statusesQuery = useQuery({
queryKey: queryKeys.dateStatuses(),
queryFn: () => apiClient.api.datesControllerGetStatuses(),
})
const updateStatus = useMutation({
mutationFn: ({ dateId, statusId }: { dateId: string, statusId: string }) =>
apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId: pid() }, { statusId }),
onSuccess: (_, { dateId, statusId }) => {
qc.setQueryData<any[]>(
queryKeys.dates(pid()),
old => old?.map(d => d.id === dateId ? { ...d, statusId } : d),
)
},
})
return { datesQuery, statusesQuery, updateStatus }
}

View File

@@ -0,0 +1,26 @@
import type { MatchDto, ProfileResponseDto } from '@/api/api'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
export type EnrichedMatch = MatchDto & { partnerProfile: ProfileResponseDto }
export function useMatchesQuery(profileId: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => queryKeys.matches(toValue(profileId) ?? '')),
queryFn: async (): Promise<EnrichedMatch[]> => {
const pid = toValue(profileId)!
const matchDtos = await apiClient.api.likesControllerGetMyMatches({ profileId: pid })
return Promise.all(
matchDtos.map(async (m) => {
const partnerProfileId = m.profile1Id === pid ? m.profile2Id : m.profile1Id
const partnerProfile = await apiClient.api.profilesControllerFindOne(partnerProfileId)
return { ...m, partnerProfile }
}),
)
},
enabled: computed(() => !!toValue(profileId)),
})
}

View File

@@ -0,0 +1,38 @@
import type { MessageDto, SendMessageDto } from '@/api/api'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
const POLL_INTERVAL = 2000
export function useMessagesQuery(
chatId: MaybeRefOrGetter<string>,
profileId: MaybeRefOrGetter<string | undefined>,
) {
const qc = useQueryClient()
const cid = () => toValue(chatId)
const pid = () => toValue(profileId) ?? ''
const query = useQuery({
queryKey: computed(() => queryKeys.messages(cid(), pid())),
queryFn: () => apiClient.api.chatControllerGetMessages({ chatId: cid(), profileId: pid() }),
enabled: computed(() => !!toValue(profileId)),
refetchInterval: POLL_INTERVAL,
refetchIntervalInBackground: false,
})
const sendMessage = useMutation({
mutationFn: (dto: SendMessageDto) =>
apiClient.api.chatControllerSendMessage({ chatId: cid(), profileId: pid() }, dto),
onSuccess: (newMsg) => {
qc.setQueryData<MessageDto[]>(
queryKeys.messages(cid(), pid()),
old => (old ? [...old, newMsg] : [newMsg]),
)
},
})
return { query, sendMessage }
}

View File

@@ -0,0 +1,24 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
export function useProfileQuery(profileId: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => queryKeys.profile(toValue(profileId) ?? '')),
queryFn: () => apiClient.api.profilesControllerFindOne(toValue(profileId)!),
enabled: computed(() => !!toValue(profileId)),
})
}
export function useDeleteProfileMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (profileId: string) => apiClient.api.profilesControllerDelete(profileId),
onSuccess: (_, profileId) => {
qc.removeQueries({ queryKey: queryKeys.profile(profileId) })
},
})
}

View File

@@ -0,0 +1,16 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { queryKeys } from './keys'
export function useReportsQuery() {
const query = useQuery({
queryKey: queryKeys.reports(),
queryFn: () => apiClient.api.reportsControllerGetAll(),
})
const banUser = useMutation({
mutationFn: (userId: string) => apiClient.api.usersControllerBan(userId),
})
return { query, banUser }
}

View File

@@ -70,12 +70,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import { computed } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useUi } from '@/composables/useUi'
import { useReportsQuery } from '@/queries/useReportsQuery'
interface Report {
id: string
@@ -89,26 +89,14 @@ interface Report {
}
const uiStore = useUi()
const reports = ref<Report[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[]
reports.value = res
}
catch {
uiStore.addToast('Не удалось загрузить жалобы', 'error')
}
finally {
loading.value = false
}
})
const { query: { data: reportsData, isLoading: loading }, banUser: banUserMutation } = useReportsQuery()
const reports = computed(() => (reportsData.value as unknown as Report[]) ?? [])
async function banUser(userId: string) {
try {
await apiClient.api.usersControllerBan(userId)
await banUserMutation.mutateAsync(userId)
uiStore.addToast('Пользователь заблокирован', 'success')
}
catch {

View File

@@ -38,11 +38,11 @@
</div>
<!-- Messages -->
<div v-if="chatStore.loading" class="chat-room__loading">
<div v-if="messagesQuery.isLoading.value" class="chat-room__loading">
<LoadingSpinner size="md" />
</div>
<div v-else class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
<div v-else-if="!messagesQuery.isLoading.value" class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
<div
v-for="group in groupedMessages"
:key="group.date"
@@ -87,8 +87,8 @@
</template>
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import type { MessageDto } from '@/api/api'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ChatBubble from '@/components/chat/ChatBubble.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
@@ -96,27 +96,29 @@ import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
import { useMessagesQuery } from '@/queries/useMessagesQuery'
const route = useRoute()
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const chatId = route.params.chatId as string
const profileId = computed(() => authStore.activeProfile?.id ?? '')
const profileId = computed(() => authStore.activeProfile?.id)
const messagesEnd = ref<HTMLElement | null>(null)
const confirmClose = ref(false)
const chat = computed(() => chatStore.chats.find(c => c.id === chatId))
const { query: chatsQuery, closeChat: closeChatMutation } = useChatsQuery(profileId)
const { query: messagesQuery, sendMessage: sendMessageMutation } = useMessagesQuery(chatId, profileId)
const chat = computed(() => chatsQuery.data.value?.find(c => c.id === chatId))
const isLocked = computed(() => chat.value?.status === 'closed')
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string, messages: ChatMessage[] }> = []
const groups: Array<{ date: string, messages: MessageDto[] }> = []
let lastDate = ''
for (const msg of chatStore.messages) {
for (const msg of (messagesQuery.data.value ?? [])) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' })
if (d !== lastDate) {
groups.push({ date: d, messages: [] })
@@ -132,23 +134,11 @@ async function scrollToBottom() {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
}
onMounted(async () => {
if (!profileId.value)
return
await chatStore.fetchMessages(chatId, profileId.value)
await scrollToBottom()
chatStore.startPolling(chatId, profileId.value)
})
onUnmounted(() => {
chatStore.stopPolling()
})
watch(() => messagesQuery.data.value?.length, scrollToBottom)
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value)
return
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType })
await sendMessageMutation.mutateAsync({ text, mediaUrl, mediaType })
await scrollToBottom()
}
catch {
@@ -161,10 +151,8 @@ function goBack() {
}
async function doCloseChat() {
if (!profileId.value)
return
try {
await chatStore.closeChat(chatId, profileId.value)
await closeChatMutation.mutateAsync({ chatId })
confirmClose.value = false
goBack()
}

View File

@@ -11,14 +11,14 @@
</div>
<EmptyState
v-else-if="chatStore.chats.length === 0"
v-else-if="(chats ?? []).length === 0"
title="Нет активных чатов"
description="Найдите совпадения и начните общение"
icon="chat"
/>
<ul v-else class="chats-list__list" role="list">
<li v-for="chat in chatStore.chats" :key="chat.id">
<li v-for="chat in (chats ?? [])" :key="chat.id">
<RouterLink
:to="`/chats/${chat.id}`"
class="chat-item"
@@ -70,33 +70,16 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
await chatStore.fetchChats(profileId)
}
catch {
uiStore.addToast('Не удалось загрузить чаты', 'error')
}
finally {
loading.value = false
}
})
const { query: { data: chats, isLoading: loading } } = useChatsQuery(profileId)
function formatTime(dateStr: string) {
const d = new Date(dateStr)

View File

@@ -11,7 +11,7 @@
</div>
<EmptyState
v-else-if="dates.length === 0"
v-else-if="dates.length === 0 && !loading"
title="Нет предстоящих встреч"
description="Предложите встречу из профиля совпадения"
icon="calendar"
@@ -77,13 +77,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import { computed, ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import { useDatesQuery } from '@/queries/useDatesQuery'
interface DateStatus { id: string, name: string }
interface DateItem {
@@ -102,31 +102,17 @@ interface DateItem {
const authStore = useAuth()
const uiStore = useUi()
const dates = ref<DateItem[]>([])
const statuses = ref<DateStatus[]>([])
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
const actionLoading = ref<string | null>(null)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const [datesRes, statusesRes] = await Promise.all([
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
])
dates.value = datesRes
statuses.value = statusesRes
}
catch {
uiStore.addToast('Не удалось загрузить встречи', 'error')
}
finally {
loading.value = false
}
})
const {
datesQuery: { data: datesData, isLoading: loading },
statusesQuery: { data: statusesData },
updateStatus: updateStatusMutation,
} = useDatesQuery(profileId)
const dates = computed(() => (datesData.value as unknown as DateItem[]) ?? [])
const statuses = computed(() => (statusesData.value as unknown as DateStatus[]) ?? [])
function statusLabel(statusId: string) {
return statuses.value.find(s => s.id === statusId)?.name ?? statusId
@@ -146,15 +132,9 @@ function statusColor(statusId: string) {
}
async function updateStatus(dateId: string, statusId: string) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
actionLoading.value = `${dateId}-${statusId}`
try {
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId })
const idx = dates.value.findIndex(d => d.id === dateId)
if (idx !== -1)
dates.value[idx].statusId = statusId
await updateStatusMutation.mutateAsync({ dateId, statusId })
uiStore.addToast('Статус обновлён', 'success')
}
catch {
@@ -175,7 +155,6 @@ function formatDateTime(iso: string) {
})
}
// Pending = first two status ids roughly; accept/decline/complete = 3+
const _pendingStatusId = computed(() => statuses.value[0]?.id ?? '')
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '')
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '')

View File

@@ -86,25 +86,16 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
import FeedFilters from '@/components/feed/FeedFilters.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
const feedStore = useFeed()
const authStore = useAuth()
const filtersOpen = ref(false)
const viewMode = ref<'stack' | 'scroll'>('stack')
onMounted(() => {
const profileId = authStore.activeProfile?.id
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId)
}
})
</script>
<style scoped lang="scss">

View File

@@ -4,7 +4,7 @@
<h1 class="matches-view__title">
Совпадения
</h1>
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
<span class="meta">{{ (matches ?? []).length }} {{ (matches ?? []).length === 1 ? 'человек' : 'людей' }}</span>
</header>
<div v-if="loading" class="matches-view__loading">
@@ -12,7 +12,7 @@
</div>
<EmptyState
v-else-if="matches.length === 0"
v-else-if="(matches ?? []).length === 0"
title="Пока нет совпадений"
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
icon="heart"
@@ -20,7 +20,7 @@
<div v-else class="matches-list">
<article
v-for="match in matches"
v-for="match in (matches ?? [])"
:key="match.id"
class="match-card"
>
@@ -61,26 +61,25 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { MatchDto, ProfileResponseDto } from '@/api/api'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
type EnrichedMatch = MatchDto & { partnerProfile: ProfileResponseDto }
import { useChatsQuery } from '@/queries/useChatsQuery'
import { useMatchesQuery } from '@/queries/useMatchesQuery'
import type { EnrichedMatch } from '@/queries/useMatchesQuery'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const router = useRouter()
const matches = ref<EnrichedMatch[]>([])
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
const { data: matches, isLoading: loading } = useMatchesQuery(profileId)
const { openChat: openChatMutation } = useChatsQuery(profileId)
function calcAge(birthDate: string): number | undefined {
const birth = new Date(birthDate)
@@ -93,42 +92,14 @@ function calcAge(birthDate: string): number | undefined {
return age
}
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const matchDtos = await apiClient.api.likesControllerGetMyMatches({ profileId })
matches.value = await Promise.all(
matchDtos.map(async (m) => {
const partnerProfileId = m.profile1Id === profileId ? m.profile2Id : m.profile1Id
const partnerProfile = await apiClient.api.profilesControllerFindOne(partnerProfileId)
return { ...m, partnerProfile }
}),
)
}
catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error')
}
finally {
loading.value = false
}
})
async function openChat(match: EnrichedMatch) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
try {
const chat = await chatStore.openChat(profileId, match.id)
const chat = await openChatMutation.mutateAsync({ matchId: match.id })
router.push(`/chats/${chat.id}`)
}
catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Не удалось открыть чат'
?.response?.data?.message ?? 'Не удалось открыть чат'
uiStore.addToast(msg, 'error')
}
}

View File

@@ -137,22 +137,23 @@
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import MediaGallery from '@/components/profile/MediaGallery.vue'
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import { useDeleteProfileMutation } from '@/queries/useProfileQuery'
const authStore = useAuth()
const uiStore = useUi()
const deleteProfileMutation = useDeleteProfileMutation()
const editing = ref(false)
const confirmDelete = ref(false)
const deleting = ref(false)
const profile = computed(() => authStore.activeProfile)
const deleting = computed(() => deleteProfileMutation.isPending.value)
function onSaved(_updated: UserProfile) {
editing.value = false
@@ -161,9 +162,8 @@ function onSaved(_updated: UserProfile) {
async function doDelete() {
if (!profile.value)
return
deleting.value = true
try {
await apiClient.api.profilesControllerDelete(profile.value.id)
await deleteProfileMutation.mutateAsync(profile.value.id)
authStore.removeProfile(profile.value.id)
confirmDelete.value = false
uiStore.addToast('Профиль удалён', 'success')
@@ -171,9 +171,6 @@ async function doDelete() {
catch {
uiStore.addToast('Не удалось удалить профиль', 'error')
}
finally {
deleting.value = false
}
}
function logout() {

View File

@@ -113,43 +113,26 @@
</template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
import ReportModal from '@/components/reports/ReportModal.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useProfileQuery } from '@/queries/useProfileQuery'
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
const _chatStore = useChat()
const profileId = route.params.profileId as string
const profile = ref<UserProfile | null>(null)
const loading = ref(false)
const reportOpen = ref(false)
const dateOpen = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.profilesControllerFindOne(profileId)
profile.value = res
}
catch {
uiStore.addToast('Не удалось загрузить профиль', 'error')
}
finally {
loading.value = false
}
})
const { data: profile, isLoading: loading } = useProfileQuery(profileId)
const age = computed(() => {
if (!profile.value?.birthDate)