✨ 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
@@ -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
@@ -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: {}
|
||||
|
||||
49
scripts/generate-sprite.js
Normal 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')
|
||||
43
src/App.vue
@@ -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>
|
||||
|
||||
3
src/assets/icons/calendar.svg
Normal 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 |
3
src/assets/icons/chat.svg
Normal 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 |
3
src/assets/icons/check.svg
Normal 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 |
3
src/assets/icons/chevron-left.svg
Normal 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 |
3
src/assets/icons/chevron-right.svg
Normal 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 |
4
src/assets/icons/edit.svg
Normal 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 |
3
src/assets/icons/flag.svg
Normal 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 |
3
src/assets/icons/grid.svg
Normal 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 |
3
src/assets/icons/heart.svg
Normal 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 |
19
src/assets/icons/icon-names.ts
Normal 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'
|
||||
5
src/assets/icons/image.svg
Normal 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
@@ -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 |
4
src/assets/icons/person.svg
Normal 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 |
3
src/assets/icons/plus.svg
Normal 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 |
4
src/assets/icons/search.svg
Normal 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 |
4
src/assets/icons/send.svg
Normal 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 |
64
src/assets/icons/sprite.svg
Normal 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 |
5
src/assets/icons/trash.svg
Normal 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
@@ -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 |
37
src/components/common/AppIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
20
src/main.ts
@@ -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
@@ -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,
|
||||
}
|
||||
44
src/queries/useChatsQuery.ts
Normal 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 }
|
||||
}
|
||||
34
src/queries/useDatesQuery.ts
Normal 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 }
|
||||
}
|
||||
26
src/queries/useMatchesQuery.ts
Normal 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)),
|
||||
})
|
||||
}
|
||||
38
src/queries/useMessagesQuery.ts
Normal 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 }
|
||||
}
|
||||
24
src/queries/useProfileQuery.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
16
src/queries/useReportsQuery.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||