✨ 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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"lint": "eslint . --fix",
|
"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"
|
"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": {
|
"dependencies": {
|
||||||
|
"@tanstack/vue-query": "^5.101.0",
|
||||||
"@floating-ui/vue": "^1.1.5",
|
"@floating-ui/vue": "^1.1.5",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
|
|||||||
38
pnpm-lock.yaml
generated
@@ -13,6 +13,9 @@ importers:
|
|||||||
'@floating-ui/vue':
|
'@floating-ui/vue':
|
||||||
specifier: ^1.1.5
|
specifier: ^1.1.5
|
||||||
version: 1.1.11(vue@3.5.35(typescript@5.9.3))
|
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':
|
'@tauri-apps/api':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
@@ -1086,6 +1089,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
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':
|
'@tauri-apps/api@2.11.0':
|
||||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||||
|
|
||||||
@@ -2457,6 +2476,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
|
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
remove-accents@0.5.0:
|
||||||
|
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||||
|
|
||||||
reserved-identifiers@1.2.0:
|
reserved-identifiers@1.2.0:
|
||||||
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
|
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3373,6 +3395,20 @@ snapshots:
|
|||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(yaml@2.9.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/api@2.11.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
||||||
@@ -4998,6 +5034,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jsesc: 3.1.0
|
jsesc: 3.1.0
|
||||||
|
|
||||||
|
remove-accents@0.5.0: {}
|
||||||
|
|
||||||
reserved-identifiers@1.2.0: {}
|
reserved-identifiers@1.2.0: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import AppToast from '@/components/common/AppToast.vue'
|
import AppToast from '@/components/common/AppToast.vue'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { queryKeys } from '@/queries/keys'
|
||||||
|
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
|
|
||||||
async function loadReferences() {
|
const { data: tagsData } = useQuery({
|
||||||
if (uiStore.referencesLoaded)
|
queryKey: queryKeys.tags(),
|
||||||
return
|
queryFn: () => apiClient.api.tagsControllerFindAll(),
|
||||||
try {
|
staleTime: Infinity,
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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
|
const activeProfile = authStore.activeProfile
|
||||||
if (!activeProfile)
|
if (!activeProfile)
|
||||||
return
|
return
|
||||||
|
feedStore.removeCard(profileId)
|
||||||
|
checkRefill()
|
||||||
try {
|
try {
|
||||||
await apiClient.api.likesControllerCreateLike({
|
await apiClient.api.likesControllerCreateLike({
|
||||||
sourceProfileId: activeProfile.id,
|
sourceProfileId: activeProfile.id,
|
||||||
targetProfileId: profileId,
|
targetProfileId: profileId,
|
||||||
type: 'like',
|
type: 'like',
|
||||||
})
|
})
|
||||||
feedStore.removeCard(profileId)
|
|
||||||
checkRefill()
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
uiStore.addToast('Не удалось отправить лайк', 'error')
|
uiStore.addToast('Не удалось отправить лайк', 'error')
|
||||||
@@ -74,26 +74,23 @@ async function handleDislike(profileId: string) {
|
|||||||
const activeProfile = authStore.activeProfile
|
const activeProfile = authStore.activeProfile
|
||||||
if (!activeProfile)
|
if (!activeProfile)
|
||||||
return
|
return
|
||||||
|
feedStore.removeCard(profileId)
|
||||||
|
checkRefill()
|
||||||
try {
|
try {
|
||||||
await apiClient.api.likesControllerCreateLike({
|
await apiClient.api.likesControllerCreateLike({
|
||||||
sourceProfileId: activeProfile.id,
|
sourceProfileId: activeProfile.id,
|
||||||
targetProfileId: profileId,
|
targetProfileId: profileId,
|
||||||
type: 'dislike',
|
type: 'dislike',
|
||||||
})
|
})
|
||||||
feedStore.removeCard(profileId)
|
|
||||||
checkRefill()
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
feedStore.removeCard(profileId)
|
// Dislike failures are silent — card is already removed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRefill() {
|
function checkRefill() {
|
||||||
const activeProfile = authStore.activeProfile
|
|
||||||
if (!activeProfile)
|
|
||||||
return
|
|
||||||
if (feedStore.cards.length < 5 && feedStore.hasMore) {
|
if (feedStore.cards.length < 5 && feedStore.hasMore) {
|
||||||
feedStore.fetchNextPage(activeProfile.id)
|
feedStore.fetchNextPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ function toggleTag(tagId: string) {
|
|||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
feedStore.applyFilters({ ...filters })
|
feedStore.applyFilters({ ...filters })
|
||||||
const profileId = authStore.activeProfile?.id
|
feedStore.fetchNextPage()
|
||||||
if (profileId)
|
|
||||||
feedStore.fetchNextPage(profileId)
|
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
:aria-current="isActive(item.path) ? 'page' : undefined"
|
:aria-current="isActive(item.path) ? 'page' : undefined"
|
||||||
>
|
>
|
||||||
<span class="bottom-nav__icon">
|
<span class="bottom-nav__icon">
|
||||||
<BottomNavIcon :name="item.icon" />
|
<AppIcon :name="item.icon" :size="22" />
|
||||||
</span>
|
</span>
|
||||||
<span class="bottom-nav__label">{{ item.label }}</span>
|
<span class="bottom-nav__label">{{ item.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -19,10 +19,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
|
import type { IconName } from '@/assets/icons/icon-names'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const navItems = [
|
const navItems: { path: string, label: string, icon: IconName }[] = [
|
||||||
{ path: '/feed', label: 'Лента', icon: 'grid' },
|
{ path: '/feed', label: 'Лента', icon: 'grid' },
|
||||||
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
|
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
|
||||||
{ path: '/chats', label: 'Чаты', icon: 'chat' },
|
{ path: '/chats', label: 'Чаты', icon: 'chat' },
|
||||||
@@ -35,21 +37,6 @@ function isActive(path: string) {
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<style scoped lang="scss">
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -86,11 +73,6 @@ const BottomNavIcon = {
|
|||||||
&__icon {
|
&__icon {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
:aria-label="item.label"
|
:aria-label="item.label"
|
||||||
:aria-current="isActive(item.path) ? 'page' : undefined"
|
:aria-current="isActive(item.path) ? 'page' : undefined"
|
||||||
>
|
>
|
||||||
<span class="sidenav__icon" :aria-hidden="true">
|
<span class="sidenav__icon" aria-hidden="true">
|
||||||
<NavIcon :name="item.icon" />
|
<AppIcon :name="item.icon" :size="20" />
|
||||||
</span>
|
</span>
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<span v-if="uiStore.sidebarExpanded" class="sidenav__label">{{ item.label }}</span>
|
<span v-if="uiStore.sidebarExpanded" class="sidenav__label">{{ item.label }}</span>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<span class="sidenav__icon">
|
<span class="sidenav__icon">
|
||||||
<NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" />
|
<AppIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" :size="20" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +49,8 @@ import { computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
|
import type { IconName } from '@/assets/icons/icon-names'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
@@ -58,7 +60,7 @@ interface NavItem {
|
|||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: IconName
|
||||||
adminOnly?: boolean
|
adminOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,25 +89,6 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<style scoped lang="scss">
|
||||||
.sidenav {
|
.sidenav {
|
||||||
width: var(--sidebar-collapsed);
|
width: var(--sidebar-collapsed);
|
||||||
@@ -189,11 +172,6 @@ const NavIcon = {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
|
|||||||
@@ -1,109 +1,9 @@
|
|||||||
import type { ChatDto, MessageDto, SendMessageDto } from '@/api/api'
|
import type { MessageDto } 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ChatMessage type kept for backward compatibility with components that import it
|
||||||
export type ChatMessage = MessageDto
|
export type ChatMessage = MessageDto
|
||||||
|
|
||||||
// Polling interval — replace with WebSocket when backend supports it
|
// Chat data fetching is handled by useChatsQuery and useMessagesQuery
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
return reactive({
|
return {}
|
||||||
chats,
|
|
||||||
activeChat,
|
|
||||||
messages,
|
|
||||||
loading,
|
|
||||||
fetchChats,
|
|
||||||
fetchMessages,
|
|
||||||
sendMessage,
|
|
||||||
openChat,
|
|
||||||
closeChat,
|
|
||||||
startPolling,
|
|
||||||
stopPolling,
|
|
||||||
setActiveChat,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,72 @@
|
|||||||
import type { FeedControllerGetFeedParams, ProfileResponseDto } from '@/api/api'
|
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 { apiClient } from '@/api/client'
|
||||||
|
import { queryKeys } from '@/queries/keys'
|
||||||
|
import { useAuth } from './useAuth'
|
||||||
|
|
||||||
export type FeedProfile = ProfileResponseDto & { age?: number | null, cityName?: string }
|
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 filters = reactive<Partial<FeedControllerGetFeedParams>>({})
|
||||||
const page = ref(1)
|
|
||||||
const hasMore = ref(true)
|
|
||||||
const searchPaused = ref(false)
|
const searchPaused = ref(false)
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchNextPage(profileId: string) {
|
export function useFeed() {
|
||||||
if (loading.value || !hasMore.value)
|
const authStore = useAuth()
|
||||||
return
|
const qc = useQueryClient()
|
||||||
loading.value = true
|
const profileId = computed(() => authStore.activeProfile?.id)
|
||||||
try {
|
|
||||||
const res = await apiClient.api.feedControllerGetFeed({
|
const query = useInfiniteQuery({
|
||||||
profileId,
|
queryKey: computed(() => queryKeys.feed(profileId.value ?? '', { ...filters })),
|
||||||
page: page.value,
|
queryFn: ({ pageParam }) =>
|
||||||
|
apiClient.api.feedControllerGetFeed({
|
||||||
|
profileId: profileId.value!,
|
||||||
|
page: pageParam as number,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
...filters,
|
...filters,
|
||||||
|
}),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, allPages) =>
|
||||||
|
lastPage.items.length >= lastPage.limit ? allPages.length + 1 : undefined,
|
||||||
|
enabled: computed(() => !!profileId.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(res.items)
|
const cards = computed((): FeedProfile[] =>
|
||||||
|
query.data.value?.pages.flatMap(p => p.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>) {
|
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
|
||||||
Object.assign(filters, newFilters)
|
Object.assign(filters, newFilters)
|
||||||
reset()
|
}
|
||||||
|
|
||||||
|
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() {
|
function reset() {
|
||||||
cards.value = []
|
qc.resetQueries({ queryKey: ['feed', profileId.value ?? ''] })
|
||||||
page.value = 1
|
|
||||||
hasMore.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCard(profileId: string) {
|
return reactive({
|
||||||
cards.value = cards.value.filter(c => c.id !== profileId)
|
cards,
|
||||||
}
|
loading: query.isFetching,
|
||||||
|
hasMore: query.hasNextPage,
|
||||||
export function useFeed() {
|
searchPaused,
|
||||||
return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard })
|
filters,
|
||||||
|
fetchNextPage: () => query.fetchNextPage(),
|
||||||
|
applyFilters,
|
||||||
|
removeCard,
|
||||||
|
reset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/main.ts
@@ -1,10 +1,30 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import '@/styles/tailwind.css'
|
import '@/styles/tailwind.css'
|
||||||
import '@/styles/main.scss'
|
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)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(VueQueryPlugin, {
|
||||||
|
queryClientConfig: {
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useReportsQuery } from '@/queries/useReportsQuery'
|
||||||
|
|
||||||
interface Report {
|
interface Report {
|
||||||
id: string
|
id: string
|
||||||
@@ -89,26 +89,14 @@ interface Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
const reports = ref<Report[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
const { query: { data: reportsData, isLoading: loading }, banUser: banUserMutation } = useReportsQuery()
|
||||||
loading.value = true
|
|
||||||
try {
|
const reports = computed(() => (reportsData.value as unknown as Report[]) ?? [])
|
||||||
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[]
|
|
||||||
reports.value = res
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
uiStore.addToast('Не удалось загрузить жалобы', 'error')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function banUser(userId: string) {
|
async function banUser(userId: string) {
|
||||||
try {
|
try {
|
||||||
await apiClient.api.usersControllerBan(userId)
|
await banUserMutation.mutateAsync(userId)
|
||||||
uiStore.addToast('Пользователь заблокирован', 'success')
|
uiStore.addToast('Пользователь заблокирован', 'success')
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -38,11 +38,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div v-if="chatStore.loading" class="chat-room__loading">
|
<div v-if="messagesQuery.isLoading.value" class="chat-room__loading">
|
||||||
<LoadingSpinner size="md" />
|
<LoadingSpinner size="md" />
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="group in groupedMessages"
|
v-for="group in groupedMessages"
|
||||||
:key="group.date"
|
:key="group.date"
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatMessage } from '@/composables/useChat'
|
import type { MessageDto } from '@/api/api'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import ChatBubble from '@/components/chat/ChatBubble.vue'
|
import ChatBubble from '@/components/chat/ChatBubble.vue'
|
||||||
import ChatInput from '@/components/chat/ChatInput.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 AppModal from '@/components/common/AppModal.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useChat } from '@/composables/useChat'
|
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useChatsQuery } from '@/queries/useChatsQuery'
|
||||||
|
import { useMessagesQuery } from '@/queries/useMessagesQuery'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const chatStore = useChat()
|
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
|
|
||||||
const chatId = route.params.chatId as string
|
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 messagesEnd = ref<HTMLElement | null>(null)
|
||||||
const confirmClose = ref(false)
|
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')
|
const isLocked = computed(() => chat.value?.status === 'closed')
|
||||||
|
|
||||||
// Group messages by date
|
|
||||||
const groupedMessages = computed(() => {
|
const groupedMessages = computed(() => {
|
||||||
const groups: Array<{ date: string, messages: ChatMessage[] }> = []
|
const groups: Array<{ date: string, messages: MessageDto[] }> = []
|
||||||
let lastDate = ''
|
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' })
|
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
if (d !== lastDate) {
|
if (d !== lastDate) {
|
||||||
groups.push({ date: d, messages: [] })
|
groups.push({ date: d, messages: [] })
|
||||||
@@ -132,23 +134,11 @@ async function scrollToBottom() {
|
|||||||
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
watch(() => messagesQuery.data.value?.length, scrollToBottom)
|
||||||
if (!profileId.value)
|
|
||||||
return
|
|
||||||
await chatStore.fetchMessages(chatId, profileId.value)
|
|
||||||
await scrollToBottom()
|
|
||||||
chatStore.startPolling(chatId, profileId.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
chatStore.stopPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
|
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
|
||||||
if (!profileId.value)
|
|
||||||
return
|
|
||||||
try {
|
try {
|
||||||
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType })
|
await sendMessageMutation.mutateAsync({ text, mediaUrl, mediaType })
|
||||||
await scrollToBottom()
|
await scrollToBottom()
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
@@ -161,10 +151,8 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doCloseChat() {
|
async function doCloseChat() {
|
||||||
if (!profileId.value)
|
|
||||||
return
|
|
||||||
try {
|
try {
|
||||||
await chatStore.closeChat(chatId, profileId.value)
|
await closeChatMutation.mutateAsync({ chatId })
|
||||||
confirmClose.value = false
|
confirmClose.value = false
|
||||||
goBack()
|
goBack()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="chatStore.chats.length === 0"
|
v-else-if="(chats ?? []).length === 0"
|
||||||
title="Нет активных чатов"
|
title="Нет активных чатов"
|
||||||
description="Найдите совпадения и начните общение"
|
description="Найдите совпадения и начните общение"
|
||||||
icon="chat"
|
icon="chat"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul v-else class="chats-list__list" role="list">
|
<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
|
<RouterLink
|
||||||
:to="`/chats/${chat.id}`"
|
:to="`/chats/${chat.id}`"
|
||||||
class="chat-item"
|
class="chat-item"
|
||||||
@@ -70,33 +70,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useChat } from '@/composables/useChat'
|
import { useChatsQuery } from '@/queries/useChatsQuery'
|
||||||
import { useUi } from '@/composables/useUi'
|
|
||||||
|
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const chatStore = useChat()
|
const profileId = computed(() => authStore.activeProfile?.id)
|
||||||
const uiStore = useUi()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
const { query: { data: chats, isLoading: loading } } = useChatsQuery(profileId)
|
||||||
const profileId = authStore.activeProfile?.id
|
|
||||||
if (!profileId)
|
|
||||||
return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await chatStore.fetchChats(profileId)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
uiStore.addToast('Не удалось загрузить чаты', 'error')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(dateStr: string) {
|
function formatTime(dateStr: string) {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="dates.length === 0"
|
v-else-if="dates.length === 0 && !loading"
|
||||||
title="Нет предстоящих встреч"
|
title="Нет предстоящих встреч"
|
||||||
description="Предложите встречу из профиля совпадения"
|
description="Предложите встречу из профиля совпадения"
|
||||||
icon="calendar"
|
icon="calendar"
|
||||||
@@ -77,13 +77,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useDatesQuery } from '@/queries/useDatesQuery'
|
||||||
|
|
||||||
interface DateStatus { id: string, name: string }
|
interface DateStatus { id: string, name: string }
|
||||||
interface DateItem {
|
interface DateItem {
|
||||||
@@ -102,31 +102,17 @@ interface DateItem {
|
|||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
|
|
||||||
const dates = ref<DateItem[]>([])
|
const profileId = computed(() => authStore.activeProfile?.id)
|
||||||
const statuses = ref<DateStatus[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const actionLoading = ref<string | null>(null)
|
const actionLoading = ref<string | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
const {
|
||||||
const profileId = authStore.activeProfile?.id
|
datesQuery: { data: datesData, isLoading: loading },
|
||||||
if (!profileId)
|
statusesQuery: { data: statusesData },
|
||||||
return
|
updateStatus: updateStatusMutation,
|
||||||
loading.value = true
|
} = useDatesQuery(profileId)
|
||||||
try {
|
|
||||||
const [datesRes, statusesRes] = await Promise.all([
|
const dates = computed(() => (datesData.value as unknown as DateItem[]) ?? [])
|
||||||
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
|
const statuses = computed(() => (statusesData.value as unknown as DateStatus[]) ?? [])
|
||||||
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
|
|
||||||
])
|
|
||||||
dates.value = datesRes
|
|
||||||
statuses.value = statusesRes
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
uiStore.addToast('Не удалось загрузить встречи', 'error')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function statusLabel(statusId: string) {
|
function statusLabel(statusId: string) {
|
||||||
return statuses.value.find(s => s.id === statusId)?.name ?? statusId
|
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) {
|
async function updateStatus(dateId: string, statusId: string) {
|
||||||
const profileId = authStore.activeProfile?.id
|
|
||||||
if (!profileId)
|
|
||||||
return
|
|
||||||
actionLoading.value = `${dateId}-${statusId}`
|
actionLoading.value = `${dateId}-${statusId}`
|
||||||
try {
|
try {
|
||||||
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId })
|
await updateStatusMutation.mutateAsync({ dateId, statusId })
|
||||||
const idx = dates.value.findIndex(d => d.id === dateId)
|
|
||||||
if (idx !== -1)
|
|
||||||
dates.value[idx].statusId = statusId
|
|
||||||
uiStore.addToast('Статус обновлён', 'success')
|
uiStore.addToast('Статус обновлён', 'success')
|
||||||
}
|
}
|
||||||
catch {
|
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 _pendingStatusId = computed(() => statuses.value[0]?.id ?? '')
|
||||||
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '')
|
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '')
|
||||||
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '')
|
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '')
|
||||||
|
|||||||
@@ -86,25 +86,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
|
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
|
||||||
import FeedFilters from '@/components/feed/FeedFilters.vue'
|
import FeedFilters from '@/components/feed/FeedFilters.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
import { useFeed } from '@/composables/useFeed'
|
import { useFeed } from '@/composables/useFeed'
|
||||||
|
|
||||||
const feedStore = useFeed()
|
const feedStore = useFeed()
|
||||||
const authStore = useAuth()
|
|
||||||
|
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
const viewMode = ref<'stack' | 'scroll'>('stack')
|
const viewMode = ref<'stack' | 'scroll'>('stack')
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const profileId = authStore.activeProfile?.id
|
|
||||||
if (profileId && feedStore.cards.length === 0) {
|
|
||||||
feedStore.fetchNextPage(profileId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1 class="matches-view__title">
|
<h1 class="matches-view__title">
|
||||||
Совпадения
|
Совпадения
|
||||||
</h1>
|
</h1>
|
||||||
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
|
<span class="meta">{{ (matches ?? []).length }} {{ (matches ?? []).length === 1 ? 'человек' : 'людей' }}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="matches-view__loading">
|
<div v-if="loading" class="matches-view__loading">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="matches.length === 0"
|
v-else-if="(matches ?? []).length === 0"
|
||||||
title="Пока нет совпадений"
|
title="Пока нет совпадений"
|
||||||
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
|
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
|
||||||
icon="heart"
|
icon="heart"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<div v-else class="matches-list">
|
<div v-else class="matches-list">
|
||||||
<article
|
<article
|
||||||
v-for="match in matches"
|
v-for="match in (matches ?? [])"
|
||||||
:key="match.id"
|
:key="match.id"
|
||||||
class="match-card"
|
class="match-card"
|
||||||
>
|
>
|
||||||
@@ -61,26 +61,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 AppButton from '@/components/common/AppButton.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useChat } from '@/composables/useChat'
|
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useChatsQuery } from '@/queries/useChatsQuery'
|
||||||
type EnrichedMatch = MatchDto & { partnerProfile: ProfileResponseDto }
|
import { useMatchesQuery } from '@/queries/useMatchesQuery'
|
||||||
|
import type { EnrichedMatch } from '@/queries/useMatchesQuery'
|
||||||
|
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const chatStore = useChat()
|
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const matches = ref<EnrichedMatch[]>([])
|
const profileId = computed(() => authStore.activeProfile?.id)
|
||||||
const loading = ref(false)
|
|
||||||
|
const { data: matches, isLoading: loading } = useMatchesQuery(profileId)
|
||||||
|
const { openChat: openChatMutation } = useChatsQuery(profileId)
|
||||||
|
|
||||||
function calcAge(birthDate: string): number | undefined {
|
function calcAge(birthDate: string): number | undefined {
|
||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
@@ -93,42 +92,14 @@ function calcAge(birthDate: string): number | undefined {
|
|||||||
return age
|
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) {
|
async function openChat(match: EnrichedMatch) {
|
||||||
const profileId = authStore.activeProfile?.id
|
|
||||||
if (!profileId)
|
|
||||||
return
|
|
||||||
try {
|
try {
|
||||||
const chat = await chatStore.openChat(profileId, match.id)
|
const chat = await openChatMutation.mutateAsync({ matchId: match.id })
|
||||||
router.push(`/chats/${chat.id}`)
|
router.push(`/chats/${chat.id}`)
|
||||||
}
|
}
|
||||||
catch (err: unknown) {
|
catch (err: unknown) {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })
|
const msg = (err as { response?: { data?: { message?: string } } })
|
||||||
?.response
|
?.response?.data?.message ?? 'Не удалось открыть чат'
|
||||||
?.data
|
|
||||||
?.message ?? 'Не удалось открыть чат'
|
|
||||||
uiStore.addToast(msg, 'error')
|
uiStore.addToast(msg, 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,22 +137,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserProfile } from '@/composables/useAuth'
|
import type { UserProfile } from '@/composables/useAuth'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import AppModal from '@/components/common/AppModal.vue'
|
import AppModal from '@/components/common/AppModal.vue'
|
||||||
import MediaGallery from '@/components/profile/MediaGallery.vue'
|
import MediaGallery from '@/components/profile/MediaGallery.vue'
|
||||||
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
|
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useDeleteProfileMutation } from '@/queries/useProfileQuery'
|
||||||
|
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
|
const deleteProfileMutation = useDeleteProfileMutation()
|
||||||
|
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const confirmDelete = ref(false)
|
const confirmDelete = ref(false)
|
||||||
const deleting = ref(false)
|
|
||||||
|
|
||||||
const profile = computed(() => authStore.activeProfile)
|
const profile = computed(() => authStore.activeProfile)
|
||||||
|
const deleting = computed(() => deleteProfileMutation.isPending.value)
|
||||||
|
|
||||||
function onSaved(_updated: UserProfile) {
|
function onSaved(_updated: UserProfile) {
|
||||||
editing.value = false
|
editing.value = false
|
||||||
@@ -161,9 +162,8 @@ function onSaved(_updated: UserProfile) {
|
|||||||
async function doDelete() {
|
async function doDelete() {
|
||||||
if (!profile.value)
|
if (!profile.value)
|
||||||
return
|
return
|
||||||
deleting.value = true
|
|
||||||
try {
|
try {
|
||||||
await apiClient.api.profilesControllerDelete(profile.value.id)
|
await deleteProfileMutation.mutateAsync(profile.value.id)
|
||||||
authStore.removeProfile(profile.value.id)
|
authStore.removeProfile(profile.value.id)
|
||||||
confirmDelete.value = false
|
confirmDelete.value = false
|
||||||
uiStore.addToast('Профиль удалён', 'success')
|
uiStore.addToast('Профиль удалён', 'success')
|
||||||
@@ -171,9 +171,6 @@ async function doDelete() {
|
|||||||
catch {
|
catch {
|
||||||
uiStore.addToast('Не удалось удалить профиль', 'error')
|
uiStore.addToast('Не удалось удалить профиль', 'error')
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
|||||||
@@ -113,43 +113,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserProfile } from '@/composables/useAuth'
|
import { computed, ref } from 'vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { apiClient } from '@/api/client'
|
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import AppModal from '@/components/common/AppModal.vue'
|
import AppModal from '@/components/common/AppModal.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
|
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
|
||||||
import ReportModal from '@/components/reports/ReportModal.vue'
|
import ReportModal from '@/components/reports/ReportModal.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useChat } from '@/composables/useChat'
|
|
||||||
import { useUi } from '@/composables/useUi'
|
import { useUi } from '@/composables/useUi'
|
||||||
|
import { useProfileQuery } from '@/queries/useProfileQuery'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuth()
|
const authStore = useAuth()
|
||||||
const uiStore = useUi()
|
const uiStore = useUi()
|
||||||
const _chatStore = useChat()
|
|
||||||
|
|
||||||
const profileId = route.params.profileId as string
|
const profileId = route.params.profileId as string
|
||||||
const profile = ref<UserProfile | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const reportOpen = ref(false)
|
const reportOpen = ref(false)
|
||||||
const dateOpen = ref(false)
|
const dateOpen = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
const { data: profile, isLoading: loading } = useProfileQuery(profileId)
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await apiClient.api.profilesControllerFindOne(profileId)
|
|
||||||
profile.value = res
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
uiStore.addToast('Не удалось загрузить профиль', 'error')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const age = computed(() => {
|
const age = computed(() => {
|
||||||
if (!profile.value?.birthDate)
|
if (!profile.value?.birthDate)
|
||||||
|
|||||||