refactor(composables): migrate stores →

composables, align with updated API

  - Replace deleted Pinia stores with
  module-level singleton composables
    (useAuth, useChat, useFeed, useUi) — all
  return reactive({...}) so
    Refs auto-unwrap in both templates and
  script code

  - Align entire codebase with new
  swagger-generated api.ts types:
    · TagDto.value  (was .name) — FeedCard,
  FeedFilters, ProfileEditor,
      ProfileSetupView, MyProfileView,
  ProfileDetailView, useUi
    · MediaItemDto[] / .path  (was mediaUrls[],
  avatarUrl) — FeedCard,
      FeedView, MyProfileView,
  ProfileDetailView
    · ChatDto.status 'active'|'closed'  (was
  isActive: boolean) —
      ChatRoomView, ChatsListView
    · MessageDto.profileId  (was senderId) —
  ChatRoomView, ChatBubble
    · MeResponseDto → fetchMe now calls /me +
  /profiles/my in parallel
    · Token refresh: res.data.data.accessToken
  (nested wrapper) —
      router/index.ts aligned with client.ts
  interceptor

  - Fix FeedCard, ChatBubble imports pointing
  to deleted store files
  - Fix ProfileSetupView form type to avoid
  string|undefined on v-model
  - Fix history.back() → window.history.back()
  via goBack() helper
  - Fix chat.unreadCount possibly-undefined
  guard in ChatsListView
  - Fix MapPicker Leaflet icon cast (as unknown
  as Record<string, unknown>)
This commit is contained in:
Oscar
2026-06-08 15:01:54 +03:00
parent f5e34f3a97
commit 10d696f4ca
41 changed files with 913 additions and 673 deletions

View File

@@ -5,11 +5,11 @@ Vue 3 + Vite + Tauri v2. Работает как PWA в браузере и ка
## Стек
| Слой | Технология |
|---|---|
|---|-------------------------------------------------|
| UI framework | Vue 3 (Composition API, `<script setup>`) |
| Build tool | Vite 6 |
| Desktop shell | Tauri v2 |
| State management | Pinia |
| State management | Composables | |
| Routing | Vue Router 4 |
| HTTP client | Axios (сгенерированный клиент `src/api/api.ts`) |
| Форм-валидация | Vuelidate |

View File

@@ -8,7 +8,7 @@
"build": "vue-tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"get: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": {
"@floating-ui/vue": "^1.1.5",
@@ -22,7 +22,6 @@
"esbuild": "^0.28.0",
"gsap": "^3.12.5",
"leaflet": "^1.9.4",
"pinia": "^2.2.2",
"vue": "^3.5.6",
"vue-router": "^4.4.5"
},

22
pnpm-lock.yaml generated
View File

@@ -43,9 +43,6 @@ importers:
leaflet:
specifier: ^1.9.4
version: 1.9.4
pinia:
specifier: ^2.2.2
version: 2.3.1(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
vue:
specifier: ^3.5.6
version: 3.5.35(typescript@5.9.3)
@@ -1278,15 +1275,6 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pinia@2.3.1:
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -2347,16 +2335,6 @@ snapshots:
picomatch@4.0.4: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)):
dependencies:
'@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))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@vue/composition-api'
postcss-value-parser@4.2.0: {}
postcss@8.5.15:

View File

@@ -2,11 +2,11 @@
import { onMounted, onUnmounted } from 'vue';
import AppShell from '@/components/layout/AppShell.vue';
import AppToast from '@/components/common/AppToast.vue';
import { useUiStore } from '@/stores/ui.store';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { Tag, City, Greeting } from '@/stores/ui.store';
import type { Tag, City, Greeting } from '@/composables/useUi';
const uiStore = useUiStore();
const uiStore = useUi();
async function loadReferences() {
if (uiStore.referencesLoaded) return;

View File

@@ -16,6 +16,13 @@ export interface RegisterDto {
password: string;
}
export interface TokensResponseDto {
/** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." */
accessToken: string;
/** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." */
refreshToken: string;
}
export interface LoginDto {
/** @example "+79991234567" */
phone: string;
@@ -23,10 +30,48 @@ export interface LoginDto {
password: string;
}
export interface MessageResponseDto {
/** @example "Operation successful" */
message: string;
}
export interface RefreshTokenDto {
refreshToken: string;
}
export interface RoleDto {
id: string;
name: string;
}
export interface ProfileSummaryDto {
id: string;
name: string;
gender: "male" | "female";
}
export interface MeResponseDto {
id: string;
phone: string;
status: "active" | "banned" | "pending";
roleId?: string | null;
tariffId?: string | null;
paymentId?: string | null;
fcmToken?: string | null;
role?: RoleDto | null;
profiles: ProfileSummaryDto[];
}
export interface UserResponseDto {
id: string;
phone: string;
status: "active" | "banned" | "pending";
roleId?: string | null;
tariffId?: string | null;
paymentId?: string | null;
fcmToken?: string | null;
}
export interface CreateProfileDto {
name: string;
/** @example "1995-06-15" */
@@ -41,6 +86,52 @@ export interface CreateProfileDto {
tagIds?: string[];
}
export interface CityDto {
id: string;
name: string;
lat: string;
lng: string;
}
export interface DistrictDto {
id: string;
cityId: string;
name: string;
}
export interface TagDto {
id: string;
value: string;
}
export interface MediaItemDto {
id: string;
profileId: string;
path: string;
type: "photo" | "video" | "audio";
sortOrder: number;
}
export interface ProfileResponseDto {
id: string;
userId: string;
name: string;
/** @example "1995-06-15" */
birthDate: string;
gender: "male" | "female";
cityId?: string | null;
districtId?: string | null;
description?: string | null;
nation?: string | null;
height?: number | null;
weight?: number | null;
activeChatId?: string | null;
city?: CityDto | null;
district?: DistrictDto | null;
tags: TagDto[];
media: MediaItemDto[];
}
export interface UpdateProfileDto {
name?: string;
/** @example "1995-06-15" */
@@ -63,6 +154,26 @@ export interface CreateLikeDto {
type: "like" | "dislike";
}
export interface LikeDto {
id: string;
sourceProfileId: string;
targetProfileId: string;
type: "like" | "dislike";
createdAt: string;
}
export interface MatchDto {
id: string;
profile1Id: string;
profile2Id: string;
createdAt: string;
}
export interface CreateLikeResponseDto {
like: LikeDto;
match?: MatchDto | null;
}
export interface CreateChatDto {
/** Your profile ID */
profileId: string;
@@ -70,6 +181,23 @@ export interface CreateChatDto {
matchId: string;
}
export interface ChatDto {
id: string;
profile1Id: string;
profile2Id: string;
status: "active" | "closed";
}
export interface MessageDto {
id: string;
chatId: string;
profileId: string;
text?: string | null;
mediaUrl?: string | null;
mediaType?: "photo" | "voice" | "video" | null;
createdAt: string;
}
export interface SendMessageDto {
text?: string;
mediaUrl?: string;
@@ -87,6 +215,26 @@ export interface CreateDateDto {
statusId?: string;
}
export interface DateDto {
id: string;
profile1Id: string;
profile2Id: string;
lat: string;
lng: string;
time: string;
statusId?: string | null;
}
export interface DateStatusDto {
id: string;
text: string;
}
export interface DateWithStatusDto {
date: DateDto;
date_status?: DateStatusDto | null;
}
export interface UpdateDateStatusDto {
statusId: string;
}
@@ -99,6 +247,56 @@ export interface CreateReportDto {
description?: string;
}
export interface ReportDto {
id: string;
sourceProfileId: string;
entityId: string;
entityType: "profile" | "message";
description?: string | null;
}
export interface TagResponseDto {
id: string;
value: string;
}
export interface CityResponseDto {
id: string;
name: string;
lat: string;
lng: string;
}
export interface DistrictResponseDto {
id: string;
cityId: string;
name: string;
}
export interface CreateCityDto {
/** @example "Москва" */
name: string;
/** @example 55.7558 */
lat: number;
/** @example 37.6173 */
lng: number;
}
export interface CreateDistrictDto {
/** @example "Центральный" */
name: string;
}
export interface GreetingDto {
id: string;
text: string;
}
export interface AuthControllerUpdateFcmTokenPayload {
/** @example "firebase-token-abc123" */
fcmToken: string;
}
export interface MediaControllerUploadParams {
type: string;
profileId: string;
@@ -129,6 +327,10 @@ export interface ChatControllerGetChatsParams {
export interface ChatControllerGetMessagesParams {
profileId: string;
/** @default 50 */
limit?: number;
/** @default 1 */
page?: number;
chatId: string;
}
@@ -151,6 +353,16 @@ export interface DatesControllerUpdateStatusParams {
id: string;
}
export interface TagsControllerCreatePayload {
/** @example "Спорт" */
value: string;
}
export interface GreetingsControllerCreatePayload {
/** @example "Привет!" */
text: string;
}
import axios, { AxiosInstance, AxiosRequestConfig, HeadersDefaults, ResponseType } from "axios";
export type QueryParamsType = Record<string | number, any>;
@@ -307,11 +519,12 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/auth/register
*/
authControllerRegister: (data: RegisterDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<TokensResponseDto, any>({
path: `/api/v1/auth/register`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -324,11 +537,12 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/auth/login
*/
authControllerLogin: (data: LoginDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<TokensResponseDto, any>({
path: `/api/v1/auth/login`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -342,10 +556,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
authControllerLogout: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/auth/logout`,
method: "POST",
secure: true,
format: "json",
...params,
}),
@@ -358,11 +573,12 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/auth/refresh
*/
authControllerRefresh: (data: RefreshTokenDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<TokensResponseDto, any>({
path: `/api/v1/auth/refresh`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -375,11 +591,14 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/auth/fcm-token
* @secure
*/
authControllerUpdateFcmToken: (params: RequestParams = {}) =>
this.http.request<void, any>({
authControllerUpdateFcmToken: (data: AuthControllerUpdateFcmTokenPayload, params: RequestParams = {}) =>
this.http.request<MessageResponseDto, any>({
path: `/api/v1/auth/fcm-token`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -393,10 +612,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
usersControllerGetMe: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MeResponseDto, any>({
path: `/api/v1/users/me`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -410,10 +630,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
usersControllerFindOne: (id: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<UserResponseDto, any>({
path: `/api/v1/users/${id}`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -427,10 +648,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
usersControllerBan: (id: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/users/${id}/ban`,
method: "PATCH",
secure: true,
format: "json",
...params,
}),
@@ -444,10 +666,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
usersControllerActivate: (id: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/users/${id}/activate`,
method: "PATCH",
secure: true,
format: "json",
...params,
}),
@@ -461,12 +684,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
profilesControllerCreate: (data: CreateProfileDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ProfileResponseDto, any>({
path: `/api/v1/profiles`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -480,10 +704,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
profilesControllerGetMyProfiles: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ProfileResponseDto[], any>({
path: `/api/v1/profiles/my`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -497,12 +722,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
profilesControllerUpdate: (profileId: string, data: UpdateProfileDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ProfileResponseDto, any>({
path: `/api/v1/profiles/${profileId}`,
method: "PUT",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -516,10 +742,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
profilesControllerFindOne: (profileId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ProfileResponseDto, any>({
path: `/api/v1/profiles/${profileId}`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -533,10 +760,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
profilesControllerDelete: (profileId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/profiles/${profileId}`,
method: "DELETE",
secure: true,
format: "json",
...params,
}),
@@ -550,11 +778,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
mediaControllerUpload: ({ profileId, ...query }: MediaControllerUploadParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MediaItemDto, any>({
path: `/api/v1/profiles/${profileId}/media/upload`,
method: "POST",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -568,10 +797,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
mediaControllerGetMedia: (profileId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MediaItemDto[], any>({
path: `/api/v1/profiles/${profileId}/media`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -585,10 +815,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
mediaControllerDeleteMedia: (mediaId: string, profileId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/profiles/${profileId}/media/${mediaId}`,
method: "DELETE",
secure: true,
format: "json",
...params,
}),
@@ -602,11 +833,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
feedControllerGetFeed: (query: FeedControllerGetFeedParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ProfileResponseDto[], any>({
path: `/api/v1/feed`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -620,12 +852,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
likesControllerCreateLike: (data: CreateLikeDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<CreateLikeResponseDto, any>({
path: `/api/v1/likes`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -639,11 +872,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
likesControllerGetMyMatches: (query: LikesControllerGetMyMatchesParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MatchDto[], any>({
path: `/api/v1/likes/matches`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -657,12 +891,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
chatControllerCreateChat: (data: CreateChatDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ChatDto, any>({
path: `/api/v1/chats`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -676,11 +911,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
chatControllerGetChats: (query: ChatControllerGetChatsParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ChatDto[], any>({
path: `/api/v1/chats`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -694,11 +930,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
chatControllerGetMessages: ({ chatId, ...query }: ChatControllerGetMessagesParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageDto[], any>({
path: `/api/v1/chats/${chatId}/messages`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -716,13 +953,14 @@ export class Api<SecurityDataType extends unknown> {
data: SendMessageDto,
params: RequestParams = {},
) =>
this.http.request<void, any>({
this.http.request<MessageDto, any>({
path: `/api/v1/chats/${chatId}/messages`,
method: "POST",
query: query,
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -736,11 +974,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
chatControllerCloseChat: ({ chatId, ...query }: ChatControllerCloseChatParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/chats/${chatId}`,
method: "DELETE",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -754,12 +993,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
datesControllerCreate: (data: CreateDateDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<DateDto, any>({
path: `/api/v1/dates`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -773,11 +1013,12 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
datesControllerGetDates: (query: DatesControllerGetDatesParams, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<DateWithStatusDto[], any>({
path: `/api/v1/dates`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
@@ -795,13 +1036,14 @@ export class Api<SecurityDataType extends unknown> {
data: UpdateDateStatusDto,
params: RequestParams = {},
) =>
this.http.request<void, any>({
this.http.request<DateDto, any>({
path: `/api/v1/dates/${id}/status`,
method: "PATCH",
query: query,
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -815,10 +1057,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
datesControllerGetStatuses: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<DateStatusDto[], any>({
path: `/api/v1/dates/statuses`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -832,12 +1075,13 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
reportsControllerCreate: (data: CreateReportDto, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ReportDto, any>({
path: `/api/v1/reports`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -851,10 +1095,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
reportsControllerGetAll: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<ReportDto[], any>({
path: `/api/v1/reports`,
method: "GET",
secure: true,
format: "json",
...params,
}),
@@ -867,9 +1112,10 @@ export class Api<SecurityDataType extends unknown> {
* @request GET:/api/v1/tags
*/
tagsControllerFindAll: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<TagResponseDto[], any>({
path: `/api/v1/tags`,
method: "GET",
format: "json",
...params,
}),
@@ -882,11 +1128,14 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/tags
* @secure
*/
tagsControllerCreate: (params: RequestParams = {}) =>
this.http.request<void, any>({
tagsControllerCreate: (data: TagsControllerCreatePayload, params: RequestParams = {}) =>
this.http.request<TagResponseDto, any>({
path: `/api/v1/tags`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -900,10 +1149,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
tagsControllerDelete: (id: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/tags/${id}`,
method: "DELETE",
secure: true,
format: "json",
...params,
}),
@@ -916,9 +1166,10 @@ export class Api<SecurityDataType extends unknown> {
* @request GET:/api/v1/cities
*/
citiesControllerFindAll: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<CityResponseDto[], any>({
path: `/api/v1/cities`,
method: "GET",
format: "json",
...params,
}),
@@ -931,11 +1182,14 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/cities
* @secure
*/
citiesControllerCreateCity: (params: RequestParams = {}) =>
this.http.request<void, any>({
citiesControllerCreateCity: (data: CreateCityDto, params: RequestParams = {}) =>
this.http.request<CityResponseDto, any>({
path: `/api/v1/cities`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -948,9 +1202,10 @@ export class Api<SecurityDataType extends unknown> {
* @request GET:/api/v1/cities/{cityId}/districts
*/
citiesControllerFindDistricts: (cityId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<DistrictResponseDto[], any>({
path: `/api/v1/cities/${cityId}/districts`,
method: "GET",
format: "json",
...params,
}),
@@ -963,11 +1218,14 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/cities/{cityId}/districts
* @secure
*/
citiesControllerCreateDistrict: (cityId: string, params: RequestParams = {}) =>
this.http.request<void, any>({
citiesControllerCreateDistrict: (cityId: string, data: CreateDistrictDto, params: RequestParams = {}) =>
this.http.request<DistrictResponseDto, any>({
path: `/api/v1/cities/${cityId}/districts`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -980,9 +1238,10 @@ export class Api<SecurityDataType extends unknown> {
* @request GET:/api/v1/greetings
*/
greetingsControllerFindAll: (params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<GreetingDto[], any>({
path: `/api/v1/greetings`,
method: "GET",
format: "json",
...params,
}),
@@ -995,11 +1254,14 @@ export class Api<SecurityDataType extends unknown> {
* @request POST:/api/v1/greetings
* @secure
*/
greetingsControllerCreate: (params: RequestParams = {}) =>
this.http.request<void, any>({
greetingsControllerCreate: (data: GreetingsControllerCreatePayload, params: RequestParams = {}) =>
this.http.request<GreetingDto, any>({
path: `/api/v1/greetings`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
@@ -1013,10 +1275,11 @@ export class Api<SecurityDataType extends unknown> {
* @secure
*/
greetingsControllerDelete: (id: string, params: RequestParams = {}) =>
this.http.request<void, any>({
this.http.request<MessageResponseDto, any>({
path: `/api/v1/greetings/${id}`,
method: "DELETE",
secure: true,
format: "json",
...params,
}),
};

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatMessage } from '@/stores/chat.store';
import type { ChatMessage } from '@/composables/useChat';
import MediaMessage from './MediaMessage.vue';
const props = defineProps<{

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useUiStore } from '@/stores/ui.store';
const uiStore = useUiStore();
import { useUi } from '@/composables/useUi';
const uiStore = useUi();
</script>
<template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import MapPicker from './MapPicker.vue';
import AppButton from '@/components/common/AppButton.vue';
@@ -10,8 +10,8 @@ import AppInput from '@/components/common/AppInput.vue';
const props = defineProps<{ partnerProfileId: string }>();
const emit = defineEmits<{ close: []; created: [] }>();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({
time: '',

View File

@@ -16,7 +16,7 @@ onMounted(async () => {
await import('leaflet/dist/leaflet.css');
// Fix Leaflet default icon paths
delete (L.Icon.Default.prototype as Record<string, unknown>)._getIconUrl;
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { gsap } from 'gsap';
import type { FeedProfile } from '@/stores/feed.store';
import type { FeedProfile } from '@/composables/useFeed';
import ProfileBadge from './ProfileBadge.vue';
const props = defineProps<{
@@ -28,7 +28,7 @@ const age = computed(() => {
});
const coverUrl = computed(() =>
props.profile.mediaUrls?.[currentImageIndex.value] || props.profile.avatarUrl || '',
props.profile.media?.[currentImageIndex.value]?.path ?? '',
);
// ─── Drag / swipe mechanics ───────────────────────────────────────────────────
@@ -113,7 +113,7 @@ function openProfile() {
}
function nextImage() {
if (currentImageIndex.value < (props.profile.mediaUrls?.length ?? 1) - 1) {
if (currentImageIndex.value < (props.profile.media?.length ?? 1) - 1) {
currentImageIndex.value++;
}
}
@@ -161,9 +161,9 @@ function onTouchEnd(e: TouchEvent) {
</div>
<!-- Image gallery dots -->
<div v-if="(profile.mediaUrls?.length ?? 0) > 1" class="feed-card__dots">
<div v-if="(profile.media?.length ?? 0) > 1" class="feed-card__dots">
<button
v-for="(_, i) in profile.mediaUrls"
v-for="(_, i) in profile.media"
:key="i"
class="feed-card__dot"
:class="{ 'feed-card__dot--active': i === currentImageIndex }"
@@ -192,7 +192,7 @@ function onTouchEnd(e: TouchEvent) {
</div>
<h2 class="feed-card__name">{{ profile.name }}<span class="feed-card__age">, {{ age }}</span></h2>
<div v-if="profile.tags?.length" class="feed-card__tags">
<ProfileBadge v-for="tag in profile.tags?.slice(0, 4)" :key="tag.id" :label="tag.name" />
<ProfileBadge v-for="tag in profile.tags?.slice(0, 4)" :key="tag.id" :label="tag.value" />
</div>
</div>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { gsap } from 'gsap';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import { apiClient } from '@/api/client';
import { useUiStore } from '@/stores/ui.store';
import { useUi } from '@/composables/useUi';
import FeedCard from './FeedCard.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const feedStore = useFeedStore();
const authStore = useAuthStore();
const uiStore = useUiStore();
const feedStore = useFeed();
const authStore = useAuth();
const uiStore = useUi();
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useUiStore } from '@/stores/ui.store';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import { useUi } from '@/composables/useUi';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import { apiClient } from '@/api/client';
import type { District } from '@/stores/ui.store';
import type { District } from '@/composables/useUi';
import AppButton from '@/components/common/AppButton.vue';
import AppDrawer from '@/components/common/AppDrawer.vue';
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const uiStore = useUiStore();
const feedStore = useFeedStore();
const authStore = useAuthStore();
const uiStore = useUi();
const feedStore = useFeed();
const authStore = useAuth();
const filters = reactive({
cityId: '',
@@ -103,7 +103,7 @@ function reset() {
class="filters__tag"
:class="{ 'filters__tag--active': filters.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)"
>{{ tag.name }}</button>
>{{ tag.value }}</button>
</div>
</div>

View File

@@ -4,10 +4,10 @@ import { useRoute } from 'vue-router';
import TauriTitlebar from './TauriTitlebar.vue';
import SideNav from './SideNav.vue';
import BottomNav from './BottomNav.vue';
import { useAuthStore } from '@/stores/auth.store';
import { useAuth } from '@/composables/useAuth';
const route = useRoute();
const authStore = useAuthStore();
const authStore = useAuth();
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
const route = useRoute();
const authStore = useAuthStore();
const navItems = [
{ path: '/feed', label: 'Лента', icon: 'grid' },

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
interface NavItem {
name: string;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useUiStore } from '@/stores/ui.store';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
interface MediaItem {
@@ -12,7 +12,7 @@ interface MediaItem {
const props = defineProps<{ profileId: string; editable?: boolean }>();
const emit = defineEmits<{ updated: [] }>();
const uiStore = useUiStore();
const uiStore = useUi();
const items = ref<MediaItem[]>([]);
const loading = ref(false);
const uploading = ref(false);

View File

@@ -2,21 +2,21 @@
import { reactive, watch, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useUiStore } from '@/stores/ui.store';
import { useAuthStore } from '@/stores/auth.store';
import { useProfileStore } from '@/stores/profile.store';
import { useUi } from '@/composables/useUi';
import { useAuth } from '@/composables/useAuth';
import { useProfile } from '@/composables/useProfile';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import type { District } from '@/stores/ui.store';
import type { UserProfile } from '@/composables/useAuth';
import type { District } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const props = defineProps<{ profile: UserProfile }>();
const emit = defineEmits<{ saved: [UserProfile]; cancel: [] }>();
const uiStore = useUiStore();
const authStore = useAuthStore();
const profileStore = useProfileStore();
const uiStore = useUi();
const authStore = useAuth();
const profileStore = useProfile();
const form = reactive({
name: props.profile.name,
@@ -28,7 +28,7 @@ const form = reactive({
nation: props.profile.nation ?? '',
height: props.profile.height ?? undefined as number | undefined,
weight: props.profile.weight ?? undefined as number | undefined,
tagIds: [...(props.profile.tagIds ?? [])],
tagIds: props.profile.tags?.map((t) => t.id) ?? [],
});
const districts = ref<District[]>([]);
@@ -136,7 +136,7 @@ async function save() {
class="profile-editor__tag"
:class="{ 'profile-editor__tag--active': form.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)"
>{{ tag.name }}</button>
>{{ tag.value }}</button>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppModal from '@/components/common/AppModal.vue';
import AppButton from '@/components/common/AppButton.vue';
@@ -14,8 +14,8 @@ const props = defineProps<{
const emit = defineEmits<{ close: [] }>();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ description: '' });
const loading = ref(false);

130
src/composables/useAuth.ts Normal file
View File

@@ -0,0 +1,130 @@
import { ref, computed, reactive } from 'vue';
import { apiClient, _setAccessToken, _clearAuth } from '@/api/client';
import type { LoginDto, RegisterDto, TagDto, MediaItemDto } from '@/api/api';
export interface UserProfile {
id: string;
userId: string;
name: string;
birthDate: string;
gender: 'male' | 'female';
cityId?: string | null;
districtId?: string | null;
description?: string | null;
nation?: string | null;
height?: number | null;
weight?: number | null;
activeChatId?: string | null;
tags: TagDto[];
media: MediaItemDto[];
}
export interface AuthUser {
id: string;
phone: string;
status: 'active' | 'banned' | 'pending';
roleId?: string | null;
role?: { id: string; name: string } | null;
profiles: UserProfile[];
}
const user = ref<AuthUser | null>(null);
const activeProfileId = ref<string | null>(null);
const isAuthenticated = computed(() => !!user.value);
const isAdmin = computed(() => user.value?.role?.name === 'admin');
const profiles = computed(() => user.value?.profiles ?? []);
const activeProfile = computed(() =>
profiles.value.find((p) => p.id === activeProfileId.value) ?? profiles.value[0] ?? null,
);
const hasProfiles = computed(() => profiles.value.length > 0);
async function login(dto: LoginDto) {
const res = await apiClient.api.authControllerLogin(dto) as unknown as {
accessToken: string;
refreshToken: string;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
await fetchMe();
}
async function register(dto: RegisterDto) {
const res = await apiClient.api.authControllerRegister(dto) as unknown as {
accessToken: string;
refreshToken: string;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
await fetchMe();
}
async function logout() {
try {
await apiClient.api.authControllerLogout();
} catch {
// ignore errors on logout
}
_clearAuth();
user.value = null;
activeProfileId.value = null;
}
async function fetchMe() {
const [meRes, profilesRes] = await Promise.all([
apiClient.api.usersControllerGetMe(),
apiClient.api.profilesControllerGetMyProfiles(),
]);
const fullProfiles = profilesRes as unknown as UserProfile[];
user.value = { ...meRes, profiles: fullProfiles } as unknown as AuthUser;
if (fullProfiles.length > 0 && !activeProfileId.value) {
activeProfileId.value = fullProfiles[0].id;
}
}
function setActiveProfile(profileId: string) {
activeProfileId.value = profileId;
}
function addProfile(profile: UserProfile) {
if (user.value) {
user.value.profiles.push(profile);
activeProfileId.value = profile.id;
}
}
function updateProfile(updated: UserProfile) {
if (user.value) {
const idx = user.value.profiles.findIndex((p) => p.id === updated.id);
if (idx !== -1) user.value.profiles[idx] = updated;
}
}
function removeProfile(profileId: string) {
if (user.value) {
user.value.profiles = user.value.profiles.filter((p) => p.id !== profileId);
if (activeProfileId.value === profileId) {
activeProfileId.value = user.value.profiles[0]?.id ?? null;
}
}
}
export function useAuth() {
return reactive({
user,
activeProfileId,
isAuthenticated,
isAdmin,
profiles,
activeProfile,
hasProfiles,
login,
register,
logout,
fetchMe,
setActiveProfile,
addProfile,
updateProfile,
removeProfile,
});
}

117
src/composables/useChat.ts Normal file
View File

@@ -0,0 +1,117 @@
import { ref, reactive } from 'vue';
import { apiClient } from '@/api/client';
import type { SendMessageDto } from '@/api/api';
export interface ChatProfile {
id: string;
name: string;
avatarUrl?: string;
}
export interface Chat {
id: string;
profile1Id: string;
profile2Id: string;
status: 'active' | 'closed';
partner?: ChatProfile;
lastMessage?: ChatMessage;
unreadCount?: number;
createdAt?: string;
}
export interface ChatMessage {
id: string;
chatId: string;
profileId: string;
text?: string;
mediaUrl?: string;
mediaType?: 'photo' | 'voice' | 'video';
createdAt: string;
}
// 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 }) as unknown as Chat[];
chats.value = res;
}
async function fetchMessages(chatId: string, profileId: string) {
loading.value = true;
try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[];
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) as unknown as ChatMessage;
messages.value.push(res);
return res;
}
async function openChat(profileId: string, matchId: string) {
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat;
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 }) as unknown as ChatMessage[];
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() {
return reactive({
chats,
activeChat,
messages,
loading,
fetchChats,
fetchMessages,
sendMessage,
openChat,
closeChat,
startPolling,
stopPolling,
setActiveChat,
});
}

View File

@@ -0,0 +1,66 @@
import { ref, reactive } from 'vue';
import { apiClient } from '@/api/client';
import type { FeedControllerGetFeedParams, TagDto, MediaItemDto } from '@/api/api';
export interface FeedProfile {
id: string;
name: string;
birthDate: string;
gender: 'male' | 'female';
cityId?: string | null;
cityName?: string;
districtId?: string | null;
description?: string | null;
nation?: string | null;
height?: number | null;
weight?: number | null;
tags?: TagDto[];
media?: MediaItemDto[];
}
const cards = ref<FeedProfile[]>([]);
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,
}) as unknown as FeedProfile[];
if (page.value === 1) cards.value = res;
else cards.value.push(...res);
hasMore.value = res.length >= 20;
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 });
}

View File

@@ -0,0 +1,39 @@
import { ref } from 'vue';
import { apiClient } from '@/api/client';
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api';
import type { UserProfile } from './useAuth';
const currentProfile = ref<UserProfile | null>(null);
const loading = ref(false);
async function fetchProfile(profileId: string) {
loading.value = true;
try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
currentProfile.value = res;
return res;
} finally {
loading.value = false;
}
}
async function createProfile(dto: CreateProfileDto) {
const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile;
currentProfile.value = res;
return res;
}
async function updateProfile(profileId: string, dto: UpdateProfileDto) {
const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile;
currentProfile.value = res;
return res;
}
async function deleteProfile(profileId: string) {
await apiClient.api.profilesControllerDelete(profileId);
currentProfile.value = null;
}
export function useProfile() {
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile };
}

82
src/composables/useUi.ts Normal file
View File

@@ -0,0 +1,82 @@
import { ref, reactive } from 'vue';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export interface Tag {
id: string;
value: string;
}
export interface City {
id: string;
name: string;
}
export interface District {
id: string;
name: string;
cityId: string;
}
export interface Greeting {
id: string;
text: string;
}
const toasts = ref<Toast[]>([]);
const sidebarExpanded = ref(false);
const tags = ref<Tag[]>([]);
const cities = ref<City[]>([]);
const districts = reactive<Record<string, District[]>>({});
const greetings = ref<Greeting[]>([]);
const referencesLoaded = ref(false);
function addToast(message: string, type: ToastType = 'info', duration = 4000) {
const id = `${Date.now()}-${Math.random()}`;
toasts.value.push({ id, type, message, duration });
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}
function removeToast(id: string) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
function setSidebarExpanded(value: boolean) {
sidebarExpanded.value = value;
}
function setTags(data: Tag[]) { tags.value = data; }
function setCities(data: City[]) { cities.value = data; }
function setDistricts(cityId: string, data: District[]) { districts[cityId] = data; }
function setGreetings(data: Greeting[]) { greetings.value = data; }
function setReferencesLoaded() { referencesLoaded.value = true; }
export function useUi() {
return reactive({
toasts,
sidebarExpanded,
tags,
cities,
districts,
greetings,
referencesLoaded,
addToast,
removeToast,
setSidebarExpanded,
setTags,
setCities,
setDistricts,
setGreetings,
setReferencesLoaded,
});
}

View File

@@ -1,13 +1,10 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { router } from './router';
import App from './App.vue';
import '@/styles/tailwind.css';
import '@/styles/main.scss';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.use(router);
app.mount('#app');

View File

@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useAuth } from '@/composables/useAuth';
import { _getAccessToken, _setAccessToken } from '@/api/client';
import axios from 'axios';
@@ -45,7 +45,7 @@ export const router = createRouter({
let _initDone = false;
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore();
const authStore = useAuth();
// First navigation: try to restore session from localStorage refresh token
if (!_initDone) {
@@ -53,12 +53,12 @@ router.beforeEach(async (to, _from, next) => {
const storedRefresh = localStorage.getItem('refreshToken');
if (storedRefresh && !_getAccessToken()) {
try {
const res = await axios.post<{ accessToken: string; refreshToken: string }>(
const res = await axios.post<{ data: { accessToken: string; refreshToken: string } }>(
`${BASE_URL}/api/v1/auth/refresh`,
{ refreshToken: storedRefresh },
);
_setAccessToken(res.data.accessToken);
localStorage.setItem('refreshToken', res.data.refreshToken);
_setAccessToken(res.data.data.accessToken);
localStorage.setItem('refreshToken', res.data.data.refreshToken);
await authStore.fetchMe();
} catch {
localStorage.removeItem('refreshToken');

View File

@@ -1,125 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { apiClient, _setAccessToken, _clearAuth } from '@/api/client';
import type { LoginDto, RegisterDto } from '@/api/api';
export interface UserProfile {
id: string;
name: string;
birthDate: string;
gender: 'male' | 'female';
cityId?: string;
districtId?: string;
description?: string;
nation?: string;
height?: number;
weight?: number;
tagIds?: string[];
mediaUrls?: string[];
avatarUrl?: string;
}
export interface AuthUser {
id: string;
phone: string;
role: 'user' | 'admin';
isActive: boolean;
profiles: UserProfile[];
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null);
const activeProfileId = ref<string | null>(null);
const isAuthenticated = computed(() => !!user.value);
const isAdmin = computed(() => user.value?.role === 'admin');
const profiles = computed(() => user.value?.profiles ?? []);
const activeProfile = computed(() =>
profiles.value.find((p) => p.id === activeProfileId.value) ?? profiles.value[0] ?? null,
);
const hasProfiles = computed(() => profiles.value.length > 0);
async function login(dto: LoginDto) {
const res = await apiClient.api.authControllerLogin(dto) as unknown as {
accessToken: string;
refreshToken: string;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
await fetchMe();
}
async function register(dto: RegisterDto) {
const res = await apiClient.api.authControllerRegister(dto) as unknown as {
accessToken: string;
refreshToken: string;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
await fetchMe();
}
async function logout() {
try {
await apiClient.api.authControllerLogout();
} catch {
// ignore errors on logout
}
_clearAuth();
user.value = null;
activeProfileId.value = null;
}
async function fetchMe() {
const res = await apiClient.api.usersControllerGetMe() as unknown as AuthUser;
user.value = res;
if (res.profiles?.length > 0 && !activeProfileId.value) {
activeProfileId.value = res.profiles[0].id;
}
}
function setActiveProfile(profileId: string) {
activeProfileId.value = profileId;
}
function addProfile(profile: UserProfile) {
if (user.value) {
user.value.profiles.push(profile);
activeProfileId.value = profile.id;
}
}
function updateProfile(updated: UserProfile) {
if (user.value) {
const idx = user.value.profiles.findIndex((p) => p.id === updated.id);
if (idx !== -1) user.value.profiles[idx] = updated;
}
}
function removeProfile(profileId: string) {
if (user.value) {
user.value.profiles = user.value.profiles.filter((p) => p.id !== profileId);
if (activeProfileId.value === profileId) {
activeProfileId.value = user.value.profiles[0]?.id ?? null;
}
}
}
return {
user,
activeProfileId,
isAuthenticated,
isAdmin,
profiles,
activeProfile,
hasProfiles,
login,
register,
logout,
fetchMe,
setActiveProfile,
addProfile,
updateProfile,
removeProfile,
};
});

View File

@@ -1,117 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient } from '@/api/client';
import type { SendMessageDto } from '@/api/api';
export interface ChatProfile {
id: string;
name: string;
avatarUrl?: string;
}
export interface Chat {
id: string;
matchId: string;
isActive: boolean;
partner: ChatProfile;
lastMessage?: ChatMessage;
unreadCount: number;
createdAt: string;
}
export interface ChatMessage {
id: string;
chatId: string;
senderId: string;
text?: string;
mediaUrl?: string;
mediaType?: 'photo' | 'voice' | 'video';
createdAt: string;
}
// Polling interval — replace with WebSocket when backend supports it
const POLL_INTERVAL = 2000;
export const useChatStore = defineStore('chat', () => {
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 }) as unknown as Chat[];
chats.value = res;
}
async function fetchMessages(chatId: string, profileId: string) {
loading.value = true;
try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[];
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) as unknown as ChatMessage;
messages.value.push(res);
return res;
}
async function openChat(profileId: string, matchId: string) {
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat;
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 }) as unknown as ChatMessage[];
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;
}
return {
chats,
activeChat,
messages,
loading,
fetchChats,
fetchMessages,
sendMessage,
openChat,
closeChat,
startPolling,
stopPolling,
setActiveChat,
};
});

View File

@@ -1,71 +0,0 @@
import { defineStore } from 'pinia';
import { ref, reactive } from 'vue';
import { apiClient } from '@/api/client';
import type { FeedControllerGetFeedParams } from '@/api/api';
export interface FeedProfile {
id: string;
name: string;
birthDate: string;
age: number;
gender: 'male' | 'female';
cityId?: string;
cityName?: string;
districtId?: string;
districtName?: string;
description?: string;
nation?: string;
height?: number;
weight?: number;
tags?: Array<{ id: string; name: string }>;
mediaUrls: string[];
avatarUrl?: string;
}
export const useFeedStore = defineStore('feed', () => {
const cards = ref<FeedProfile[]>([]);
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,
}) as unknown as { items: FeedProfile[]; hasMore: boolean; searchPaused?: boolean };
if (page.value === 1) cards.value = res.items;
else cards.value.push(...res.items);
hasMore.value = res.hasMore;
searchPaused.value = res.searchPaused ?? false;
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);
}
return { cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard };
});

View File

@@ -1,40 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient } from '@/api/client';
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api';
import type { UserProfile } from './auth.store';
export const useProfileStore = defineStore('profile', () => {
const currentProfile = ref<UserProfile | null>(null);
const loading = ref(false);
async function fetchProfile(profileId: string) {
loading.value = true;
try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
currentProfile.value = res;
return res;
} finally {
loading.value = false;
}
}
async function createProfile(dto: CreateProfileDto) {
const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile;
currentProfile.value = res;
return res;
}
async function updateProfile(profileId: string, dto: UpdateProfileDto) {
const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile;
currentProfile.value = res;
return res;
}
async function deleteProfile(profileId: string) {
await apiClient.api.profilesControllerDelete(profileId);
currentProfile.value = null;
}
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile };
});

View File

@@ -1,83 +0,0 @@
import { defineStore } from 'pinia';
import { ref, reactive } from 'vue';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export interface Tag {
id: string;
name: string;
}
export interface City {
id: string;
name: string;
}
export interface District {
id: string;
name: string;
cityId: string;
}
export interface Greeting {
id: string;
text: string;
}
export const useUiStore = defineStore('ui', () => {
const toasts = ref<Toast[]>([]);
const sidebarExpanded = ref(false);
const tags = ref<Tag[]>([]);
const cities = ref<City[]>([]);
const districts = reactive<Record<string, District[]>>({});
const greetings = ref<Greeting[]>([]);
const referencesLoaded = ref(false);
function addToast(message: string, type: ToastType = 'info', duration = 4000) {
const id = `${Date.now()}-${Math.random()}`;
toasts.value.push({ id, type, message, duration });
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}
function removeToast(id: string) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
function setSidebarExpanded(value: boolean) {
sidebarExpanded.value = value;
}
function setTags(data: Tag[]) { tags.value = data; }
function setCities(data: City[]) { cities.value = data; }
function setDistricts(cityId: string, data: District[]) { districts[cityId] = data; }
function setGreetings(data: Greeting[]) { greetings.value = data; }
function setReferencesLoaded() { referencesLoaded.value = true; }
return {
toasts,
sidebarExpanded,
tags,
cities,
districts,
greetings,
referencesLoaded,
addToast,
removeToast,
setSidebarExpanded,
setTags,
setCities,
setDistricts,
setGreetings,
setReferencesLoaded,
};
});

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useUiStore } from '@/stores/ui.store';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
@@ -17,7 +17,7 @@ interface Report {
reporterName?: string;
}
const uiStore = useUiStore();
const uiStore = useUi();
const reports = ref<Report[]>([]);
const loading = ref(false);

View File

@@ -3,15 +3,15 @@ import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ phone: '', password: '' });
const loading = ref(false);

View File

@@ -3,14 +3,14 @@ import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ phone: '', password: '', confirmPassword: '' });
const loading = ref(false);

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { ChatMessage } from '@/stores/chat.store';
import type { ChatMessage } from '@/composables/useChat';
import ChatBubble from '@/components/chat/ChatBubble.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import AppModal from '@/components/common/AppModal.vue';
@@ -13,9 +13,9 @@ import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const chatId = route.params.chatId as string;
const profileId = computed(() => authStore.activeProfile?.id ?? '');
@@ -23,7 +23,7 @@ const messagesEnd = ref<HTMLElement | null>(null);
const confirmClose = ref(false);
const chat = computed(() => chatStore.chats.find((c) => c.id === chatId));
const isLocked = computed(() => chat.value && !chat.value.isActive);
const isLocked = computed(() => chat.value?.status === 'closed');
// Group messages by date
const groupedMessages = computed(() => {
@@ -66,12 +66,14 @@ async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voic
}
}
function goBack() { window.history.back(); }
async function doCloseChat() {
if (!profileId.value) return;
try {
await chatStore.closeChat(chatId, profileId.value);
confirmClose.value = false;
history.back();
goBack();
} catch {
uiStore.addToast('Не удалось закрыть чат', 'error');
}
@@ -82,20 +84,20 @@ async function doCloseChat() {
<div class="chat-room">
<!-- Header -->
<header class="chat-room__header">
<button class="chat-room__back" @click="history.back()" aria-label="Назад">
<button class="chat-room__back" @click="goBack()" aria-label="Назад">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
</button>
<div class="chat-room__partner">
<img
v-if="chat?.partner.avatarUrl"
v-if="chat?.partner?.avatarUrl"
:src="chat.partner.avatarUrl"
class="chat-room__avatar"
:alt="chat.partner.name"
:alt="chat.partner?.name"
/>
<div v-else class="chat-room__avatar chat-room__avatar--placeholder" />
<span class="chat-room__name">{{ chat?.partner.name ?? 'Чат' }}</span>
<span class="chat-room__name">{{ chat?.partner?.name ?? 'Чат' }}</span>
</div>
<button
class="chat-room__close-btn"
@@ -135,7 +137,7 @@ async function doCloseChat() {
v-for="msg in group.messages"
:key="msg.id"
:message="msg"
:is-mine="msg.senderId === authStore.activeProfile?.id"
:is-mine="msg.profileId === authStore.activeProfile?.id"
/>
</div>
<div ref="messagesEnd" />

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const loading = ref(false);
onMounted(async () => {
@@ -55,13 +55,13 @@ function formatTime(dateStr: string) {
<RouterLink
:to="`/chats/${chat.id}`"
class="chat-item"
:class="{ 'chat-item--inactive': !chat.isActive }"
:class="{ 'chat-item--inactive': chat.status === 'closed' }"
>
<div class="chat-item__avatar-wrap">
<img
v-if="chat.partner.avatarUrl"
v-if="chat.partner?.avatarUrl"
:src="chat.partner.avatarUrl"
:alt="chat.partner.name"
:alt="chat.partner?.name"
class="chat-item__avatar"
/>
<div v-else class="chat-item__avatar chat-item__avatar--placeholder">
@@ -70,7 +70,7 @@ function formatTime(dateStr: string) {
</svg>
</div>
<!-- Lock icon for inactive chats -->
<div v-if="!chat.isActive" class="chat-item__lock" aria-label="Чат неактивен">
<div v-if="chat.status === 'closed'" class="chat-item__lock" aria-label="Чат неактивен">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
<rect x="3" y="7" width="10" height="8" rx="1"/>
<path d="M5 7V5a3 3 0 0 1 6 0v2"/>
@@ -80,7 +80,7 @@ function formatTime(dateStr: string) {
<div class="chat-item__content">
<div class="chat-item__top">
<span class="chat-item__name">{{ chat.partner.name }}</span>
<span class="chat-item__name">{{ chat.partner?.name }}</span>
<span v-if="chat.lastMessage" class="chat-item__time meta">
{{ formatTime(chat.lastMessage.createdAt) }}
</span>
@@ -91,7 +91,7 @@ function formatTime(dateStr: string) {
<p v-else class="chat-item__preview chat-item__preview--empty">Начните переписку</p>
</div>
<div v-if="chat.unreadCount > 0" class="chat-item__badge">{{ chat.unreadCount }}</div>
<div v-if="(chat.unreadCount ?? 0) > 0" class="chat-item__badge">{{ chat.unreadCount }}</div>
</RouterLink>
</li>
</ul>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
@@ -22,8 +22,8 @@ interface DateItem {
isIncoming: boolean;
}
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const dates = ref<DateItem[]>([]);
const statuses = ref<DateStatus[]>([]);

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
import FeedFilters from '@/components/feed/FeedFilters.vue';
import AppButton from '@/components/common/AppButton.vue';
const feedStore = useFeedStore();
const authStore = useAuthStore();
const feedStore = useFeed();
const authStore = useAuth();
const filtersOpen = ref(false);
const viewMode = ref<'stack' | 'scroll'>('stack');
@@ -83,8 +83,8 @@ onMounted(() => {
>
<RouterLink :to="`/profile/${profile.id}`" class="feed-grid__link">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
v-if="profile.media?.[0]?.path"
:src="profile.media[0].path"
:alt="profile.name"
class="feed-grid__img"
/>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import { useRouter } from 'vue-router';
import AppButton from '@/components/common/AppButton.vue';
@@ -23,9 +23,9 @@ interface Match {
hasChat: boolean;
}
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const router = useRouter();
const matches = ref<Match[]>([]);

View File

@@ -3,35 +3,35 @@ import { reactive, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { CreateProfileDto } from '@/api/api';
import type { UserProfile } from '@/stores/auth.store';
import type { District } from '@/stores/ui.store';
import type { UserProfile } from '@/composables/useAuth';
import type { District } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const step = ref(1);
const totalSteps = 4;
const loading = ref(false);
const form = reactive<CreateProfileDto & { confirmStep?: number }>({
const form = reactive({
name: '',
birthDate: '',
gender: 'female',
gender: 'female' as 'female' | 'male',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined,
weight: undefined,
tagIds: [],
height: undefined as number | undefined | null,
weight: undefined as number | undefined | null,
tagIds: [] as string[],
});
const selectedTags = ref<string[]>([]);
@@ -85,7 +85,7 @@ async function finish() {
loading.value = true;
try {
form.tagIds = selectedTags.value;
const profile = await apiClient.api.profilesControllerCreate(form) as unknown as UserProfile;
const profile = await apiClient.api.profilesControllerCreate(form as unknown as CreateProfileDto) as unknown as UserProfile;
authStore.addProfile(profile);
uiStore.addToast('Профиль создан', 'success');
router.replace('/feed');
@@ -207,7 +207,7 @@ function skip() {
:class="{ 'setup__tag--active': selectedTags.includes(tag.id) }"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
{{ tag.value }}
</button>
</div>
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import type { UserProfile } from '@/composables/useAuth';
import ProfileEditor from '@/components/profile/ProfileEditor.vue';
import MediaGallery from '@/components/profile/MediaGallery.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
const authStore = useAuthStore();
const uiStore = useUiStore();
const authStore = useAuth();
const uiStore = useUi();
const editing = ref(false);
const confirmDelete = ref(false);
@@ -41,12 +41,12 @@ function logout() {
authStore.logout();
}
function cityName(cityId?: string) {
function cityName(cityId?: string | null) {
return uiStore.cities.find((c) => c.id === cityId)?.name ?? '';
}
function tagNames(tagIds?: string[]) {
return (tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id);
function tagValues(tags?: Array<{ id: string; value: string }>) {
return (tags ?? []).map((t) => t.value);
}
function calcAge(birthDate: string) {
@@ -66,8 +66,8 @@ function calcAge(birthDate: string) {
<div class="my-profile__hero">
<div class="my-profile__avatar-wrap">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
v-if="profile.media?.[0]?.path"
:src="profile.media[0].path"
:alt="profile.name"
class="my-profile__avatar"
/>
@@ -113,10 +113,10 @@ function calcAge(birthDate: string) {
</div>
<!-- Tags -->
<div v-if="profile.tagIds?.length" class="my-profile__section">
<div v-if="profile.tags?.length" class="my-profile__section">
<h3 class="my-profile__section-title">Интересы</h3>
<div class="my-profile__tags">
<span v-for="name in tagNames(profile.tagIds)" :key="name" class="my-profile__tag">{{ name }}</span>
<span v-for="name in tagValues(profile.tags)" :key="name" class="my-profile__tag">{{ name }}</span>
</div>
</div>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useChatStore } from '@/stores/chat.store';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { useChat } from '@/composables/useChat';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import type { UserProfile } from '@/composables/useAuth';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
import ReportModal from '@/components/reports/ReportModal.vue';
@@ -13,9 +13,9 @@ import DateProposalForm from '@/components/dates/DateProposalForm.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
const chatStore = useChatStore();
const authStore = useAuth();
const uiStore = useUi();
const chatStore = useChat();
const profileId = route.params.profileId as string;
const profile = ref<UserProfile | null>(null);
@@ -44,13 +44,18 @@ const age = computed(() => {
return a;
});
const cityName = computed(() => uiStore.cities.find((c) => c.id === profile.value?.cityId)?.name ?? '');
const tagNames = computed(() => (profile.value?.tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id));
const cityName = computed(() => {
const cid = profile.value?.cityId;
return cid ? uiStore.cities.find((c) => c.id === cid)?.name ?? '' : '';
});
const tagNames = computed(() => profile.value?.tags?.map((t) => t.value) ?? []);
const currentImageIndex = ref(0);
const isOwnProfile = computed(() =>
authStore.profiles.some((p) => p.id === profileId),
);
function goBack() { window.history.back(); }
</script>
<template>
@@ -63,8 +68,8 @@ const isOwnProfile = computed(() =>
<!-- Image strip -->
<div class="profile-detail__cover">
<img
v-if="profile.mediaUrls?.[currentImageIndex]"
:src="profile.mediaUrls[currentImageIndex]"
v-if="profile.media?.[currentImageIndex]?.path"
:src="profile.media[currentImageIndex].path"
:alt="profile.name"
class="profile-detail__cover-img"
/>
@@ -78,14 +83,14 @@ const isOwnProfile = computed(() =>
aria-label="Предыдущее фото"
></button>
<button
v-if="currentImageIndex < (profile.mediaUrls?.length ?? 1) - 1"
v-if="currentImageIndex < (profile.media?.length ?? 1) - 1"
class="profile-detail__img-nav profile-detail__img-nav--next"
@click="currentImageIndex++"
aria-label="Следующее фото"
></button>
<!-- Back button -->
<button class="profile-detail__back" @click="history.back()" aria-label="Назад">
<button class="profile-detail__back" @click="goBack()" aria-label="Назад">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>